use crate::catalog::DependencyType;
use regex::Regex;
use std::sync::LazyLock;
const NESTED_PATTERN_TEMPLATE: &str = r"'(\w+)',\s*{}.data";
const ARRAY_PATTERN_TEMPLATE: &str = r"'(\w+)',\s*(?:coalesce\s*\()?\s*jsonb_agg\s*\(\s*{}.data";
static INLINE_ARRAY_PATTERN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"'(\w+)',\s*(?:coalesce\s*\()?\s*jsonb_agg\s*\(\s*jsonb_build_object\s*\(")
.expect("INLINE_ARRAY_PATTERN_REGEX is valid")
});
static ARRAY_PATTERN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"'(\w+)',\s*(?:coalesce\s*\()?\s*jsonb_agg\s*\(\s*v_(\w+)\.data")
.expect("ARRAY_PATTERN_REGEX is valid")
});
const DEFAULT_ARRAY_MATCH_KEY: &str = "id";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyInfo {
pub dep_type: DependencyType,
pub jsonb_path: Option<Vec<String>>,
pub array_match_key: Option<String>,
}
impl DependencyInfo {
const fn scalar() -> Self {
Self {
dep_type: DependencyType::Scalar,
jsonb_path: None,
array_match_key: None,
}
}
fn nested_object(path: String) -> Self {
Self {
dep_type: DependencyType::NestedObject,
jsonb_path: Some(vec![path]),
array_match_key: None,
}
}
fn array(path: String, match_key: String) -> Self {
Self {
dep_type: DependencyType::Array,
jsonb_path: Some(vec![path]),
array_match_key: Some(match_key),
}
}
}
#[must_use]
pub fn analyze_dependencies(select_sql: &str, fk_columns: &[String]) -> Vec<DependencyInfo> {
let mut deps = Vec::new();
for fk_col in fk_columns {
let dep_info = detect_dependency_type(select_sql, fk_col);
deps.push(dep_info);
}
let array_deps = detect_array_dependencies(select_sql);
deps.extend(array_deps);
deps
}
fn infer_view_name(fk_col: &str) -> Option<String> {
if !fk_col.starts_with("fk_") {
return None;
}
let entity = &fk_col[3..];
if entity.is_empty() {
return None;
}
Some(format!("v_{entity}"))
}
fn detect_dependency_type(select_sql: &str, fk_col: &str) -> DependencyInfo {
let sql_normalized = select_sql.replace(['\n', '\t'], " ").to_lowercase();
let Some(view_name) = infer_view_name(fk_col) else {
return DependencyInfo::scalar();
};
let nested_pattern = NESTED_PATTERN_TEMPLATE.replace("{}", ®ex::escape(&view_name));
if let Ok(re) = Regex::new(&nested_pattern)
&& let Some(captures) = re.captures(&sql_normalized)
&& let Some(key_match) = captures.get(1)
{
let key_name = key_match.as_str().to_string();
return DependencyInfo::nested_object(key_name);
}
let array_pattern = ARRAY_PATTERN_TEMPLATE.replace("{}", ®ex::escape(&view_name));
if let Ok(re) = Regex::new(&array_pattern)
&& let Some(captures) = re.captures(&sql_normalized)
&& let Some(key_match) = captures.get(1)
{
let array_name = key_match.as_str().to_string();
return DependencyInfo::array(array_name, DEFAULT_ARRAY_MATCH_KEY.to_string());
}
DependencyInfo::scalar()
}
fn detect_array_dependencies(select_sql: &str) -> Vec<DependencyInfo> {
let mut deps = Vec::new();
let mut seen_keys = std::collections::HashSet::new();
let sql_normalized = select_sql.replace(['\n', '\t'], " ").to_lowercase();
for capture in ARRAY_PATTERN_REGEX.captures_iter(&sql_normalized) {
if let Some(array_name) = capture.get(1) {
let key = array_name.as_str().to_string();
if seen_keys.insert(key.clone()) {
deps.push(DependencyInfo::array(
key,
DEFAULT_ARRAY_MATCH_KEY.to_string(),
));
}
}
}
for capture in INLINE_ARRAY_PATTERN_REGEX.captures_iter(&sql_normalized) {
if let Some(array_name) = capture.get(1) {
let key = array_name.as_str().to_string();
if seen_keys.insert(key.clone()) {
deps.push(DependencyInfo::array(
key,
DEFAULT_ARRAY_MATCH_KEY.to_string(),
));
}
}
}
deps
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_nested_object_simple() {
let sql = r"
SELECT pk_post, fk_user,
jsonb_build_object('id', id, 'author', v_user.data) AS data
FROM tb_post
LEFT JOIN v_user ON v_user.pk_user = fk_user
";
let fk_cols = vec!["fk_user".to_string()];
let deps = analyze_dependencies(sql, &fk_cols);
assert_eq!(deps.len(), 1, "Should detect 1 dependency");
assert_eq!(deps[0].dep_type, DependencyType::NestedObject);
assert_eq!(deps[0].jsonb_path, Some(vec!["author".to_string()]));
assert_eq!(deps[0].array_match_key, None);
}
#[test]
fn test_detect_array_simple() {
let sql = r"
SELECT pk_user,
jsonb_build_object(
'id', id,
'posts', jsonb_agg(v_post.data ORDER BY created_at)
) AS data
FROM tb_user
LEFT JOIN v_post ON v_post.fk_user = pk_user
GROUP BY pk_user, id
";
let fk_cols = vec!["fk_post".to_string()];
let deps = analyze_dependencies(sql, &fk_cols);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].dep_type, DependencyType::Array);
assert_eq!(deps[0].jsonb_path, Some(vec!["posts".to_string()]));
assert_eq!(deps[0].array_match_key, Some("id".to_string())); }
#[test]
fn test_detect_scalar_direct_column() {
let sql = r"
SELECT pk_post, jsonb_build_object('id', id, 'title', title) AS data
FROM tb_post
";
let fk_cols = vec![];
let deps = analyze_dependencies(sql, &fk_cols);
assert_eq!(deps.len(), 0, "No dependencies for scalar-only TVIEW");
}
#[test]
fn test_detect_multiple_dependencies() {
let sql = r"
SELECT pk_post, fk_user, fk_category,
jsonb_build_object(
'id', id,
'title', title,
'author', v_user.data,
'category', v_category.data,
'comments', jsonb_agg(v_comment.data)
) AS data
FROM tb_post
LEFT JOIN v_user ON v_user.pk_user = fk_user
LEFT JOIN v_category ON v_category.pk_category = fk_category
LEFT JOIN v_comment ON v_comment.fk_post = pk_post
GROUP BY pk_post, fk_user, fk_category, v_user.data, v_category.data
";
let fk_cols = vec![
"fk_user".to_string(),
"fk_category".to_string(),
"fk_comment".to_string(),
];
let deps = analyze_dependencies(sql, &fk_cols);
assert_eq!(deps.len(), 3);
assert_eq!(deps[0].dep_type, DependencyType::NestedObject);
assert_eq!(deps[0].jsonb_path, Some(vec!["author".to_string()]));
assert_eq!(deps[1].dep_type, DependencyType::NestedObject);
assert_eq!(deps[1].jsonb_path, Some(vec!["category".to_string()]));
assert_eq!(deps[2].dep_type, DependencyType::Array);
assert_eq!(deps[2].jsonb_path, Some(vec!["comments".to_string()]));
assert_eq!(deps[2].array_match_key, Some("id".to_string()));
}
#[test]
fn test_detect_inline_jsonb_agg() {
let sql = "SELECT pk_post, jsonb_build_object(
'title', p.title,
'comments', COALESCE(jsonb_agg(
jsonb_build_object('id', c.pk_comment, 'text', c.text)
ORDER BY c.pk_comment
) FILTER (WHERE c.pk_comment IS NOT NULL), '[]'::jsonb)
) AS data FROM tb_post p LEFT JOIN tb_comment c ON c.fk_post = p.pk_post";
let fk_cols = vec!["fk_user".to_string()];
let deps = analyze_dependencies(sql, &fk_cols);
assert!(
deps.iter().any(|d| d.dep_type == DependencyType::Array
&& d.jsonb_path == Some(vec!["comments".to_string()])),
"Should detect inline jsonb_agg array dependency for 'comments'"
);
}
#[test]
fn test_detect_no_fk_in_select() {
let sql = r"
SELECT pk_post, jsonb_build_object('id', id) AS data
FROM tb_post
";
let fk_cols = vec!["fk_user".to_string()];
let deps = analyze_dependencies(sql, &fk_cols);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].dep_type, DependencyType::Scalar);
assert_eq!(deps[0].jsonb_path, None);
}
}