apif_source_row/
analyzer.rs1use 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(§ion.raw_content, &mut refs);
122 }
123 match (§ion.section_type, §ion.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}