Skip to main content

apif_source_row/
analyzer.rs

1use apif_ast::{GctfDocument, SectionContent, SectionType};
2use serde_json::Value;
3use std::collections::{BTreeSet, HashMap};
4
5use crate::SourceDefinition;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct TemplateRef {
9    pub source: String,
10    pub column: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
14pub enum IndexReason {
15    TemplateLookup,
16    DimensionJoin,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct IndexRequirement {
21    pub source: String,
22    pub column: String,
23    pub reason: IndexReason,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct SourceUsagePlan {
28    pub template_refs: Vec<TemplateRef>,
29    pub required_indexes: Vec<IndexRequirement>,
30}
31
32pub struct SourceUsageAnalyzer;
33
34impl SourceUsageAnalyzer {
35    pub fn analyze(document: &GctfDocument, sources: &[SourceDefinition]) -> SourceUsagePlan {
36        let template_refs = extract_template_refs(document);
37        let mut required: BTreeSet<(String, String, IndexReason)> = BTreeSet::new();
38
39        let mut source_name_to_indexed: HashMap<String, Vec<String>> = HashMap::new();
40        for (i, s) in sources.iter().enumerate() {
41            let name = effective_source_name(s, i);
42            let indexed = s
43                .indexed_columns()
44                .into_iter()
45                .map(|x| x.to_string())
46                .collect();
47            source_name_to_indexed.insert(name, indexed);
48        }
49
50        let used_sources: BTreeSet<String> =
51            template_refs.iter().map(|r| r.source.clone()).collect();
52
53        for tr in &template_refs {
54            if let Some(indexed_cols) = source_name_to_indexed.get(&tr.source)
55                && indexed_cols.iter().any(|c| c == &tr.column)
56            {
57                required.insert((
58                    tr.source.clone(),
59                    tr.column.clone(),
60                    IndexReason::TemplateLookup,
61                ));
62            }
63        }
64
65        if let Some(primary) = sources.first() {
66            let primary_name = effective_source_name(primary, 0);
67            for (i, dim) in sources.iter().enumerate().skip(1) {
68                let dim_name = effective_source_name(dim, i);
69                if !used_sources.contains(&dim_name) {
70                    continue;
71                }
72                for key in dim.indexed_columns() {
73                    let key = key.to_string();
74                    let primary_fk_used = template_refs
75                        .iter()
76                        .any(|r| r.source == primary_name && r.column == key);
77                    if primary_fk_used {
78                        required.insert((dim_name.clone(), key, IndexReason::DimensionJoin));
79                    }
80                }
81            }
82        }
83
84        let required_indexes = required
85            .into_iter()
86            .map(|(source, column, reason)| IndexRequirement {
87                source,
88                column,
89                reason,
90            })
91            .collect();
92
93        SourceUsagePlan {
94            template_refs,
95            required_indexes,
96        }
97    }
98}
99
100pub fn effective_source_name(def: &SourceDefinition, index: usize) -> String {
101    if let Some(name) = &def.name
102        && !name.trim().is_empty()
103    {
104        return name.trim().to_string();
105    }
106    let stem = std::path::Path::new(&def.file)
107        .file_stem()
108        .map(|s| s.to_string_lossy().to_string())
109        .unwrap_or_default();
110    if stem.is_empty() {
111        format!("source_{index}")
112    } else {
113        stem
114    }
115}
116
117pub(crate) fn extract_template_refs(document: &GctfDocument) -> Vec<TemplateRef> {
118    let mut refs = Vec::new();
119    for section in &document.sections {
120        if !section.raw_content.is_empty() {
121            collect_from_string(&section.raw_content, &mut refs);
122        }
123        match (&section.section_type, &section.content) {
124            (SectionType::Request, SectionContent::Json(v))
125            | (SectionType::Response, SectionContent::Json(v))
126            | (SectionType::Error, SectionContent::Json(v)) => {
127                collect_from_json(v, &mut refs);
128            }
129            (SectionType::Request, SectionContent::JsonLines(lines))
130            | (SectionType::Response, SectionContent::JsonLines(lines)) => {
131                for v in lines {
132                    collect_from_json(v, &mut refs);
133                }
134            }
135            (SectionType::Asserts, SectionContent::Assertions(lines)) => {
136                for line in lines {
137                    collect_from_string(line, &mut refs);
138                }
139            }
140            (SectionType::Extract, SectionContent::Extract(map)) => {
141                for v in map.values() {
142                    collect_from_string(v, &mut refs);
143                }
144            }
145            _ => {}
146        }
147    }
148    refs
149}
150
151fn collect_from_json(v: &Value, out: &mut Vec<TemplateRef>) {
152    match v {
153        Value::String(s) => collect_from_string(s, out),
154        Value::Array(a) => {
155            for x in a {
156                collect_from_json(x, out);
157            }
158        }
159        Value::Object(m) => {
160            for x in m.values() {
161                collect_from_json(x, out);
162            }
163        }
164        _ => {}
165    }
166}
167
168fn collect_from_string(s: &str, out: &mut Vec<TemplateRef>) {
169    let mut rest = s;
170    while let Some(start) = rest.find("{{") {
171        let after = &rest[start + 2..];
172        let Some(end) = after.find("}}") else {
173            break;
174        };
175        let inner = after[..end].trim();
176        if let Some((source, col)) = parse_source_column(inner) {
177            out.push(TemplateRef {
178                source: source.to_string(),
179                column: col.to_string(),
180            });
181        }
182        rest = &after[end + 2..];
183    }
184}
185
186fn parse_source_column(inner: &str) -> Option<(&str, &str)> {
187    let mut parts = inner.split('.');
188    let source = parts.next()?.trim();
189    let column = parts.next()?.trim();
190    if source.is_empty() || column.is_empty() {
191        return None;
192    }
193    if parts.next().is_some() {
194        return None;
195    }
196    Some((source, column))
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn parse_doc(content: &str) -> GctfDocument {
204        apif_parser::parse_gctf_from_str(content, "test.gctf").unwrap()
205    }
206
207    #[test]
208    fn extract_request_template_refs() {
209        let doc = parse_doc(
210            r#"--- REQUEST ---
211{"user_id":"{{users.user_id}}","r":"{{regions.region_id}}"}
212"#,
213        );
214        let refs = extract_template_refs(&doc);
215        assert!(
216            refs.iter()
217                .any(|r| r.source == "users" && r.column == "user_id")
218        );
219        assert!(
220            refs.iter()
221                .any(|r| r.source == "regions" && r.column == "region_id")
222        );
223    }
224
225    #[test]
226    fn derive_required_indexes_for_dimension_join() {
227        let doc = parse_doc(
228            r#"--- REQUEST ---
229{"region":"{{regions.name}}","rid":"{{pvz.region_id}}"}
230"#,
231        );
232        let defs: Vec<SourceDefinition> = serde_yaml_ng::from_str(
233            r#"
234- name: pvz
235  file: data/pvz.csv
236- name: regions
237  file: data/regions.csv
238  indexed_by: [region_id]
239"#,
240        )
241        .unwrap();
242
243        let plan = SourceUsageAnalyzer::analyze(&doc, &defs);
244        assert!(
245            plan.required_indexes
246                .iter()
247                .any(|r| r.source == "regions" && r.column == "region_id")
248        );
249    }
250}