Skip to main content

sz_orm_core/seeding/
fixture.rs

1//! FixtureLoader — fixture 模板加载器
2//!
3//! 从 YAML/JSON 文件加载静态测试数据模板,解析关联引用,支持模板继承与覆盖。
4
5use super::{Record, SeedError};
6use serde_json::Value;
7use std::collections::HashMap;
8use std::path::Path;
9
10/// 关联引用(如 `${user.0.id}`)
11#[derive(Debug, Clone)]
12pub struct Reference {
13    /// 当前记录中引用字段名
14    pub field: String,
15    /// 目标表名
16    pub target_table: String,
17    /// 目标记录索引
18    pub target_index: usize,
19    /// 目标字段名
20    pub target_field: String,
21}
22
23/// Fixture 模板
24#[derive(Debug, Clone)]
25pub struct FixtureTemplate {
26    /// 目标表名
27    pub table: String,
28    /// 静态记录列表
29    pub records: Vec<Record>,
30    /// 生成数量
31    pub count: usize,
32    /// 关联引用列表
33    pub references: Vec<Reference>,
34    /// 继承的模板名
35    pub extends: Option<String>,
36}
37
38/// Fixture 加载器
39pub struct FixtureLoader;
40
41impl FixtureLoader {
42    /// 从文件加载 fixture 模板
43    pub fn load(path: &str) -> Result<FixtureTemplate, SeedError> {
44        let content = std::fs::read_to_string(path)?;
45        let ext = Path::new(path)
46            .extension()
47            .and_then(|e| e.to_str())
48            .unwrap_or("");
49        match ext {
50            "yaml" | "yml" => Self::parse_yaml(&content, path),
51            "json" => Self::parse_json(&content, path),
52            _ => Err(SeedError::FixtureParseFailed {
53                path: path.to_string(),
54                reason: format!("unsupported file extension: {}", ext),
55            }),
56        }
57    }
58
59    fn parse_yaml(content: &str, path: &str) -> Result<FixtureTemplate, SeedError> {
60        let yaml: serde_yaml::Value =
61            serde_yaml::from_str(content).map_err(|e| SeedError::FixtureParseFailed {
62                path: path.to_string(),
63                reason: e.to_string(),
64            })?;
65        Self::build_template(yaml, path)
66    }
67
68    fn parse_json(content: &str, path: &str) -> Result<FixtureTemplate, SeedError> {
69        let json: serde_json::Value =
70            serde_json::from_str(content).map_err(|e| SeedError::FixtureParseFailed {
71                path: path.to_string(),
72                reason: e.to_string(),
73            })?;
74        let yaml = serde_yaml::to_value(&json).map_err(|e| SeedError::FixtureParseFailed {
75            path: path.to_string(),
76            reason: e.to_string(),
77        })?;
78        Self::build_template(yaml, path)
79    }
80
81    fn build_template(yaml: serde_yaml::Value, path: &str) -> Result<FixtureTemplate, SeedError> {
82        let map = yaml
83            .as_mapping()
84            .ok_or_else(|| SeedError::FixtureParseFailed {
85                path: path.to_string(),
86                reason: "root must be a mapping".to_string(),
87            })?;
88        let table = map
89            .get(serde_yaml::Value::String("table".to_string()))
90            .and_then(|v| v.as_str())
91            .ok_or_else(|| SeedError::FixtureParseFailed {
92                path: path.to_string(),
93                reason: "missing 'table' field".to_string(),
94            })?
95            .to_string();
96        let count = map
97            .get(serde_yaml::Value::String("count".to_string()))
98            .and_then(|v| v.as_u64())
99            .unwrap_or(0) as usize;
100        let extends = map
101            .get(serde_yaml::Value::String("extends".to_string()))
102            .and_then(|v| v.as_str())
103            .map(|s| s.to_string());
104        let records = Self::extract_records(map, path)?;
105        let references = Self::extract_references(map);
106        Ok(FixtureTemplate {
107            table,
108            records,
109            count,
110            references,
111            extends,
112        })
113    }
114
115    fn extract_records(map: &serde_yaml::Mapping, path: &str) -> Result<Vec<Record>, SeedError> {
116        let fields = map.get(serde_yaml::Value::String("fields".to_string()));
117        let count = map
118            .get(serde_yaml::Value::String("count".to_string()))
119            .and_then(|v| v.as_u64())
120            .unwrap_or(1) as usize;
121        match fields {
122            Some(serde_yaml::Value::Mapping(field_map)) => {
123                let mut records = Vec::with_capacity(count);
124                for _ in 0..count {
125                    let mut record = serde_json::Map::new();
126                    for (k, v) in field_map {
127                        let key = k.as_str().unwrap_or("").to_string();
128                        let value = serde_json::to_value(v).unwrap_or(Value::Null);
129                        record.insert(key, value);
130                    }
131                    records.push(record);
132                }
133                Ok(records)
134            }
135            Some(serde_yaml::Value::Sequence(items)) => {
136                let mut records = Vec::with_capacity(items.len());
137                for item in items {
138                    if let Some(item_map) = item.as_mapping() {
139                        let mut record = serde_json::Map::new();
140                        for (k, v) in item_map {
141                            let key = k.as_str().unwrap_or("").to_string();
142                            let value = serde_json::to_value(v).unwrap_or(Value::Null);
143                            record.insert(key, value);
144                        }
145                        records.push(record);
146                    }
147                }
148                Ok(records)
149            }
150            None => Ok(Vec::new()),
151            _ => Err(SeedError::FixtureParseFailed {
152                path: path.to_string(),
153                reason: "fields must be a mapping or sequence".to_string(),
154            }),
155        }
156    }
157
158    fn extract_references(map: &serde_yaml::Mapping) -> Vec<Reference> {
159        let refs = map.get(serde_yaml::Value::String("references".to_string()));
160        match refs {
161            Some(serde_yaml::Value::Sequence(items)) => items
162                .iter()
163                .filter_map(|item| {
164                    let m = item.as_mapping()?;
165                    let field = m
166                        .get(serde_yaml::Value::String("field".to_string()))?
167                        .as_str()?
168                        .to_string();
169                    let target_table = m
170                        .get(serde_yaml::Value::String("target".to_string()))?
171                        .as_str()?
172                        .to_string();
173                    let target_index = m
174                        .get(serde_yaml::Value::String("index".to_string()))?
175                        .as_u64()? as usize;
176                    let target_field = m
177                        .get(serde_yaml::Value::String("target_field".to_string()))?
178                        .as_str()?
179                        .to_string();
180                    Some(Reference {
181                        field,
182                        target_table,
183                        target_index,
184                        target_field,
185                    })
186                })
187                .collect(),
188            _ => Vec::new(),
189        }
190    }
191
192    /// 解析关联引用 `${table.index.field}`
193    pub fn resolve_references(
194        template: &mut FixtureTemplate,
195        resolved: &HashMap<String, Vec<Record>>,
196    ) -> Result<(), SeedError> {
197        for reference in &template.references {
198            let target_records = resolved.get(&reference.target_table).ok_or_else(|| {
199                SeedError::InvalidConfig(format!(
200                    "reference target table '{}' not found",
201                    reference.target_table
202                ))
203            })?;
204            let target_record = target_records.get(reference.target_index).ok_or_else(|| {
205                SeedError::InvalidConfig(format!(
206                    "reference target index {} out of range",
207                    reference.target_index
208                ))
209            })?;
210            let target_value = target_record
211                .get(&reference.target_field)
212                .cloned()
213                .unwrap_or(Value::Null);
214            for record in &mut template.records {
215                record.insert(reference.field.clone(), target_value.clone());
216            }
217        }
218        Ok(())
219    }
220
221    /// 加载目录下所有 fixture 文件
222    pub fn load_dir(dir: &str) -> Result<Vec<FixtureTemplate>, SeedError> {
223        let mut templates = Vec::new();
224        let path = Path::new(dir);
225        if !path.exists() {
226            return Err(SeedError::Io(std::io::Error::new(
227                std::io::ErrorKind::NotFound,
228                format!("directory not found: {}", dir),
229            )));
230        }
231        let mut entries: Vec<_> = std::fs::read_dir(path)?
232            .filter_map(|e| e.ok())
233            .filter(|e| {
234                e.path()
235                    .extension()
236                    .and_then(|ext| ext.to_str())
237                    .is_some_and(|ext| ext == "yaml" || ext == "yml" || ext == "json")
238            })
239            .collect();
240        entries.sort_by_key(|e| e.path());
241        for entry in entries {
242            let path_str = entry.path().to_string_lossy().to_string();
243            templates.push(Self::load(&path_str)?);
244        }
245        Ok(templates)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use serde_json::json;
253    use std::io::Write;
254
255    fn write_temp_file(name: &str, content: &str) -> String {
256        let dir = std::env::temp_dir();
257        let path = dir.join(format!("sz_orm_fixture_{}", name));
258        let mut file = std::fs::File::create(&path).unwrap();
259        file.write_all(content.as_bytes()).unwrap();
260        path.to_string_lossy().to_string()
261    }
262
263    fn cleanup(path: &str) {
264        let _ = std::fs::remove_file(path);
265    }
266
267    #[test]
268    fn test_load_yaml_fixture() {
269        let content = r#"
270table: users
271count: 3
272fields:
273  name: "张三"
274  email: "zhangsan@example.com"
275  age: 30
276"#;
277        let path = write_temp_file("test1.yaml", content);
278        let template = FixtureLoader::load(&path).unwrap();
279        assert_eq!(template.table, "users");
280        assert_eq!(template.count, 3);
281        assert_eq!(template.records.len(), 3);
282        assert_eq!(template.records[0]["name"], "张三");
283        cleanup(&path);
284    }
285
286    #[test]
287    fn test_load_json_fixture() {
288        let content =
289            r#"{"table": "orders", "count": 2, "fields": {"order_id": 1001, "amount": 99.9}}"#;
290        let path = write_temp_file("test2.json", content);
291        let template = FixtureLoader::load(&path).unwrap();
292        assert_eq!(template.table, "orders");
293        assert_eq!(template.records.len(), 2);
294        cleanup(&path);
295    }
296
297    #[test]
298    fn test_resolve_references() {
299        let mut template = FixtureTemplate {
300            table: "orders".to_string(),
301            records: vec![serde_json::Map::new()],
302            count: 1,
303            references: vec![Reference {
304                field: "user_id".to_string(),
305                target_table: "users".to_string(),
306                target_index: 0,
307                target_field: "id".to_string(),
308            }],
309            extends: None,
310        };
311        let mut user_record = serde_json::Map::new();
312        user_record.insert("id".to_string(), json!(42));
313        let resolved: HashMap<String, Vec<Record>> = vec![("users".to_string(), vec![user_record])]
314            .into_iter()
315            .collect();
316        FixtureLoader::resolve_references(&mut template, &resolved).unwrap();
317        assert_eq!(template.records[0]["user_id"], json!(42));
318    }
319
320    #[test]
321    fn test_parse_error() {
322        let path = write_temp_file("test3.yaml", "invalid: yaml: content: [");
323        let result = FixtureLoader::load(&path);
324        assert!(result.is_err());
325        let err = result.unwrap_err();
326        assert!(matches!(err, SeedError::FixtureParseFailed { .. }));
327        cleanup(&path);
328    }
329
330    #[test]
331    fn test_unsupported_extension() {
332        let path = write_temp_file("test4.txt", "content");
333        let result = FixtureLoader::load(&path);
334        assert!(result.is_err());
335        cleanup(&path);
336    }
337}