rust_data_processing/ingestion/
xml.rs1use std::collections::HashMap;
6use std::fs::File;
7use std::io::{BufReader, Write};
8use std::path::Path;
9
10use quick_xml::Writer;
11use quick_xml::escape::unescape;
12use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
13
14use crate::error::{IngestionError, IngestionResult};
15use crate::types::{DataSet, DataType, Schema, Value};
16
17const ROOT: &str = "rdp_records";
18const RECORD: &str = "record";
19
20fn xml_err(message: impl Into<String>) -> IngestionError {
21 IngestionError::SchemaMismatch {
22 message: message.into(),
23 }
24}
25
26fn value_to_text(v: &Value) -> String {
27 match v {
28 Value::Null => String::new(),
29 Value::Int64(i) => i.to_string(),
30 Value::Float64(f) => f.to_string(),
31 Value::Bool(b) => b.to_string(),
32 Value::Utf8(s) => s.clone(),
33 }
34}
35
36fn parse_text_to_value(raw: &str, dt: &DataType) -> IngestionResult<Value> {
37 let trimmed = raw.trim();
38 if trimmed.is_empty() {
39 return Ok(Value::Null);
40 }
41 match dt {
42 DataType::Int64 => trimmed
43 .parse::<i64>()
44 .map(Value::Int64)
45 .map_err(|_| xml_err(format!("invalid Int64 in XML: {trimmed:?}"))),
46 DataType::Float64 => trimmed
47 .parse::<f64>()
48 .map(Value::Float64)
49 .map_err(|_| xml_err(format!("invalid Float64 in XML: {trimmed:?}"))),
50 DataType::Bool => match trimmed.to_ascii_lowercase().as_str() {
51 "true" | "1" | "yes" => Ok(Value::Bool(true)),
52 "false" | "0" | "no" => Ok(Value::Bool(false)),
53 _ => Err(xml_err(format!("invalid Bool in XML: {trimmed:?}"))),
54 },
55 DataType::Utf8 => Ok(Value::Utf8(trimmed.to_string())),
56 }
57}
58
59fn row_from_field_map(
60 schema: &Schema,
61 fields: &HashMap<String, String>,
62) -> IngestionResult<Vec<Value>> {
63 let mut row = Vec::with_capacity(schema.fields.len());
64 for field in &schema.fields {
65 let raw = fields.get(&field.name).map(String::as_str).unwrap_or("");
66 row.push(parse_text_to_value(raw, &field.data_type)?);
67 }
68 Ok(row)
69}
70
71pub fn export_dataset_to_xml(path: &Path, ds: &DataSet) -> IngestionResult<()> {
73 let mut file = File::create(path)?;
74 let mut writer = Writer::new_with_indent(&mut file, b' ', 2);
75 writer
76 .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
77 .map_err(|e| xml_err(format!("write xml decl: {e}")))?;
78 writer
79 .write_event(Event::Start(BytesStart::new(ROOT)))
80 .map_err(|e| xml_err(format!("write xml root: {e}")))?;
81
82 for row in &ds.rows {
83 writer
84 .write_event(Event::Start(BytesStart::new(RECORD)))
85 .map_err(|e| xml_err(format!("write record start: {e}")))?;
86 for (field, cell) in ds.schema.fields.iter().zip(row.iter()) {
87 writer
88 .write_event(Event::Start(BytesStart::new(field.name.as_str())))
89 .map_err(|e| xml_err(format!("write field start: {e}")))?;
90 let text = value_to_text(cell);
91 if !text.is_empty() {
92 writer
93 .write_event(Event::Text(BytesText::new(&text)))
94 .map_err(|e| xml_err(format!("write field text: {e}")))?;
95 }
96 writer
97 .write_event(Event::End(BytesEnd::new(field.name.as_str())))
98 .map_err(|e| xml_err(format!("write field end: {e}")))?;
99 }
100 writer
101 .write_event(Event::End(BytesEnd::new(RECORD)))
102 .map_err(|e| xml_err(format!("write record end: {e}")))?;
103 }
104
105 writer
106 .write_event(Event::End(BytesEnd::new(ROOT)))
107 .map_err(|e| xml_err(format!("write xml root end: {e}")))?;
108 writer
109 .write_event(Event::Eof)
110 .map_err(|e| xml_err(format!("write xml eof: {e}")))?;
111 file.flush()?;
112 Ok(())
113}
114
115pub fn ingest_xml_from_path(path: impl AsRef<Path>, schema: &Schema) -> IngestionResult<DataSet> {
117 let file = File::open(path.as_ref())?;
118 let reader = BufReader::new(file);
119 let mut xml = quick_xml::Reader::from_reader(reader);
120 xml.config_mut().trim_text(true);
121
122 let mut rows: Vec<Vec<Value>> = Vec::new();
123 let mut in_record = false;
124 let mut field_values: HashMap<String, String> = HashMap::new();
125 let mut active_field: Option<String> = None;
126 let mut buf = Vec::new();
127
128 loop {
129 buf.clear();
130 match xml.read_event_into(&mut buf) {
131 Ok(Event::Start(e)) => {
132 let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
133 if name == RECORD {
134 in_record = true;
135 field_values.clear();
136 } else if in_record && name != ROOT {
137 active_field = Some(name);
138 }
139 }
140 Ok(Event::Text(e)) => {
141 if let Some(ref field) = active_field {
142 let decoded = e
143 .xml10_content()
144 .map_err(|err| xml_err(format!("xml text: {err}")))?;
145 let text = unescape(&decoded)
146 .map_err(|err| xml_err(format!("xml text: {err}")))?;
147 field_values
148 .entry(field.clone())
149 .and_modify(|v| {
150 v.push(' ');
151 v.push_str(&text);
152 })
153 .or_insert_with(|| text.into_owned());
154 }
155 }
156 Ok(Event::End(e)) => {
157 let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
158 if name == RECORD && in_record {
159 rows.push(row_from_field_map(schema, &field_values)?);
160 in_record = false;
161 active_field = None;
162 } else if active_field.as_deref() == Some(name.as_str()) {
163 active_field = None;
164 }
165 }
166 Ok(Event::Eof) => break,
167 Err(e) => return Err(xml_err(format!("xml parse: {e}"))),
168 _ => {}
169 }
170 }
171
172 Ok(DataSet::new(schema.clone(), rows))
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::types::Field;
179
180 fn sample_schema() -> Schema {
181 Schema::new(vec![
182 Field::new("stationCode", DataType::Utf8),
183 Field::new("lat", DataType::Float64),
184 Field::new("label", DataType::Utf8),
185 ])
186 }
187
188 #[test]
189 fn xml_roundtrip_preserves_rows() {
190 let schema = sample_schema();
191 let ds = DataSet::new(
192 schema.clone(),
193 vec![vec![
194 Value::Utf8("ACW00011604".into()),
195 Value::Float64(17.1167),
196 Value::Utf8("ST JOHNS".into()),
197 ]],
198 );
199 let dir = std::env::temp_dir().join(format!("rdp_xml_test_{}", std::process::id()));
200 std::fs::create_dir_all(&dir).unwrap();
201 let path = dir.join("stations.xml");
202 export_dataset_to_xml(&path, &ds).unwrap();
203 let back = ingest_xml_from_path(&path, &schema).unwrap();
204 assert_eq!(back.row_count(), 1);
205 assert_eq!(back.rows[0][0], Value::Utf8("ACW00011604".into()));
206 std::fs::remove_dir_all(dir).ok();
207 }
208
209 #[test]
210 fn xml_ingest_reads_committed_fixture_shape() {
211 let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
212 let path = manifest.join("tests/fixtures/ghcn/ghcn_stations_sample.json");
213 if !path.exists() {
214 return;
215 }
216 let schema = Schema::new(vec![
217 Field::new("id", DataType::Utf8),
218 Field::new("latitude", DataType::Float64),
219 Field::new("longitude", DataType::Float64),
220 Field::new("elevation", DataType::Float64),
221 Field::new("name", DataType::Utf8),
222 Field::new("state", DataType::Utf8),
223 ]);
224 let json_ds = crate::ingestion::json::ingest_json_from_path(&path, &schema).unwrap();
225 let xml_path = manifest.join("tests/fixtures/ghcn/_tmp_roundtrip.xml");
226 export_dataset_to_xml(&xml_path, &json_ds).unwrap();
227 let xml_schema = Schema::new(vec![
228 Field::new("stationCode", DataType::Utf8),
229 Field::new("lat", DataType::Float64),
230 Field::new("lon", DataType::Float64),
231 Field::new("elev_m", DataType::Float64),
232 Field::new("label", DataType::Utf8),
233 Field::new("region", DataType::Utf8),
234 ]);
235 let xml_ds = DataSet::new(
236 xml_schema.clone(),
237 json_ds
238 .rows
239 .iter()
240 .map(|row| {
241 vec![
242 row[0].clone(),
243 row[1].clone(),
244 row[2].clone(),
245 row[3].clone(),
246 row[4].clone(),
247 row[5].clone(),
248 ]
249 })
250 .collect(),
251 );
252 export_dataset_to_xml(&xml_path, &xml_ds).unwrap();
253 let read = ingest_xml_from_path(&xml_path, &xml_schema).unwrap();
254 assert_eq!(read.row_count(), json_ds.row_count());
255 std::fs::remove_file(xml_path).ok();
256 }
257}