Skip to main content

sora_export/
protobuf.rs

1use prost::Message;
2use sora_data::model::{ConfigData, Value};
3use sora_diagnostics::{Result, SoraError};
4use sora_ir::model::ConfigIr;
5
6use crate::{
7    bundle::{FORMAT_VERSION, data_fingerprint, schema_fingerprint},
8    exporter::{DataExporter, ExportOutput, ExportRequest, OutputKind},
9    fs_util::{create_parent_dir, write_file},
10};
11
12pub struct ProtobufBundleExporter;
13
14impl DataExporter for ProtobufBundleExporter {
15    fn format_name(&self) -> &'static str {
16        "sora-protobuf"
17    }
18
19    fn output_kind(&self) -> OutputKind {
20        OutputKind::File
21    }
22
23    fn export(&self, request: ExportRequest<'_>) -> Result<()> {
24        let ExportOutput::File(path) = request.output else {
25            return Err(SoraError::InvalidExportOutput {
26                format: self.format_name().to_owned(),
27                expected: "file",
28            });
29        };
30
31        create_parent_dir(&path)?;
32        let bundle = ProtoBundle::from_data(request.ir, request.data)?;
33        write_file(path, bundle.encode_to_vec())
34    }
35}
36
37#[derive(Clone, PartialEq, Message)]
38struct ProtoBundle {
39    #[prost(uint32, tag = "1")]
40    format_version: u32,
41    #[prost(string, tag = "2")]
42    format: String,
43    #[prost(string, tag = "3")]
44    package: String,
45    #[prost(bytes = "vec", tag = "4")]
46    schema_json: Vec<u8>,
47    #[prost(message, repeated, tag = "5")]
48    tables: Vec<ProtoTable>,
49    #[prost(string, tag = "6")]
50    schema_fingerprint: String,
51    #[prost(string, tag = "7")]
52    data_fingerprint: String,
53}
54
55#[derive(Clone, PartialEq, Message)]
56struct ProtoTable {
57    #[prost(string, tag = "1")]
58    name: String,
59    #[prost(message, repeated, tag = "2")]
60    rows: Vec<ProtoRow>,
61}
62
63#[derive(Clone, PartialEq, Message)]
64struct ProtoRow {
65    #[prost(message, repeated, tag = "1")]
66    fields: Vec<ProtoField>,
67}
68
69#[derive(Clone, PartialEq, Message)]
70struct ProtoField {
71    #[prost(string, tag = "1")]
72    name: String,
73    #[prost(message, optional, tag = "2")]
74    value: Option<ProtoValue>,
75}
76
77#[derive(Clone, PartialEq, Message)]
78struct ProtoValue {
79    #[prost(oneof = "proto_value::Kind", tags = "1, 2, 3, 4, 5, 6, 7")]
80    kind: Option<proto_value::Kind>,
81}
82
83mod proto_value {
84    #[derive(Clone, PartialEq, prost::Oneof)]
85    pub enum Kind {
86        #[prost(bool, tag = "1")]
87        Bool(bool),
88        #[prost(int64, tag = "2")]
89        Integer(i64),
90        #[prost(double, tag = "3")]
91        Float(f64),
92        #[prost(string, tag = "4")]
93        String(String),
94        #[prost(message, tag = "5")]
95        List(super::ProtoList),
96        #[prost(message, tag = "6")]
97        Object(super::ProtoObject),
98        #[prost(bool, tag = "7")]
99        Null(bool),
100    }
101}
102
103#[derive(Clone, PartialEq, Message)]
104struct ProtoList {
105    #[prost(message, repeated, tag = "1")]
106    values: Vec<ProtoValue>,
107}
108
109#[derive(Clone, PartialEq, Message)]
110struct ProtoObject {
111    #[prost(message, repeated, tag = "1")]
112    fields: Vec<ProtoField>,
113}
114
115impl ProtoBundle {
116    fn from_data(ir: &ConfigIr, data: &ConfigData) -> Result<Self> {
117        let schema = ir.data_schema();
118        let schema_json = serde_json::to_vec(&schema).map_err(SoraError::SerializeData)?;
119        Ok(Self {
120            format_version: FORMAT_VERSION,
121            format: "sora-protobuf".to_owned(),
122            package: schema.package.clone(),
123            schema_json,
124            schema_fingerprint: schema_fingerprint(ir)?,
125            data_fingerprint: data_fingerprint(data)?,
126            tables: data
127                .tables
128                .iter()
129                .map(|table| ProtoTable {
130                    name: table.name.clone(),
131                    rows: table
132                        .rows
133                        .iter()
134                        .map(|row| ProtoRow {
135                            fields: row
136                                .values
137                                .iter()
138                                .map(|(name, value)| ProtoField {
139                                    name: name.clone(),
140                                    value: Some(ProtoValue::from(value)),
141                                })
142                                .collect(),
143                        })
144                        .collect(),
145                })
146                .collect(),
147        })
148    }
149}
150
151impl From<&Value> for ProtoValue {
152    fn from(value: &Value) -> Self {
153        let kind = match value {
154            Value::Bool(value) => proto_value::Kind::Bool(*value),
155            Value::Integer(value) => proto_value::Kind::Integer(*value),
156            Value::Float(value) => proto_value::Kind::Float(*value),
157            Value::String(value) => proto_value::Kind::String(value.clone()),
158            Value::List(values) => proto_value::Kind::List(ProtoList {
159                values: values.iter().map(ProtoValue::from).collect(),
160            }),
161            Value::Object(fields) => proto_value::Kind::Object(ProtoObject {
162                fields: fields
163                    .iter()
164                    .map(|(name, value)| ProtoField {
165                        name: name.clone(),
166                        value: Some(ProtoValue::from(value)),
167                    })
168                    .collect(),
169            }),
170            Value::Null => proto_value::Kind::Null(true),
171        };
172
173        Self { kind: Some(kind) }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::exporter::ExportOutput;
181    use sora_data::model::{RowData, TableData};
182    use sora_ir::{model::ConfigIr, normalize::normalize_schema};
183    use sora_schema::model::SchemaFile;
184    use std::{
185        collections::BTreeMap,
186        fs,
187        path::PathBuf,
188        sync::atomic::{AtomicUsize, Ordering},
189        time::{SystemTime, UNIX_EPOCH},
190    };
191
192    #[test]
193    fn protobuf_exporter_writes_bundle_file() {
194        let ir = example_ir();
195        let data = example_data();
196        let path = temp_dir().join("config.sora.pb");
197
198        ProtobufBundleExporter
199            .export(ExportRequest {
200                ir: &ir,
201                data: &data,
202                locale_catalog: None,
203                execution: &sora_execution::ExecutionContext::default(),
204                options: Default::default(),
205                output: ExportOutput::File(path.clone()),
206            })
207            .unwrap();
208
209        let bundle = ProtoBundle::decode(fs::read(&path).unwrap().as_slice()).unwrap();
210        assert_eq!(bundle.format_version, 1);
211        assert_eq!(bundle.format, "sora-protobuf");
212        assert_eq!(bundle.package, "game_config");
213        assert!(bundle.schema_fingerprint.len() > 8);
214        assert!(bundle.data_fingerprint.len() > 8);
215        assert_eq!(bundle.tables[0].name, "Item");
216        assert_eq!(bundle.tables[0].rows[0].fields[0].name, "id");
217
218        let _ = fs::remove_dir_all(path.parent().unwrap());
219    }
220
221    fn example_ir() -> ConfigIr {
222        let schema: SchemaFile = toml::from_str(
223            r#"
224package = "game_config"
225
226[[tables]]
227name = "Item"
228mode = "map"
229key = "id"
230
231[[tables.fields]]
232name = "id"
233type = "i32"
234"#,
235        )
236        .unwrap();
237        normalize_schema(schema).unwrap()
238    }
239
240    fn example_data() -> ConfigData {
241        ConfigData {
242            tables: vec![TableData {
243                name: "Item".to_owned(),
244                rows: vec![RowData {
245                    values: BTreeMap::from([
246                        ("id".to_owned(), Value::Integer(1001)),
247                        ("name".to_owned(), Value::String("Iron Sword".to_owned())),
248                    ]),
249                }],
250            }],
251        }
252    }
253
254    fn temp_dir() -> PathBuf {
255        static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
256        let unique = SystemTime::now()
257            .duration_since(UNIX_EPOCH)
258            .unwrap()
259            .as_nanos();
260        let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
261        std::env::temp_dir().join(format!("sora-export-protobuf-test-{unique}-{id}"))
262    }
263}