Skip to main content

sora_data/
derived.rs

1use std::{cmp::Ordering, collections::BTreeMap};
2
3use sora_diagnostics::{Result, SoraError};
4use sora_ir::model::{ConfigIr, DerivedFieldIr, FieldIr, StructIr, TableIr, TypeIr};
5
6use crate::model::{ConfigData, RowData, Value};
7
8pub fn materialize_derived_fields(ir: &ConfigIr, data: &ConfigData) -> Result<ConfigData> {
9    let mut materialized = data.clone();
10
11    for table in &ir.tables {
12        for field in table
13            .fields
14            .iter()
15            .filter(|field| field.derived_from.is_some())
16        {
17            materialize_table_derived_field(ir, data, &mut materialized, table, field)?;
18        }
19    }
20
21    Ok(materialized)
22}
23
24fn materialize_table_derived_field(
25    ir: &ConfigIr,
26    source_data: &ConfigData,
27    materialized: &mut ConfigData,
28    parent_table: &TableIr,
29    field: &FieldIr,
30) -> Result<()> {
31    let derived_from = field
32        .derived_from
33        .as_ref()
34        .expect("caller filters to derived fields");
35    let shape = derived_field_shape(ir, field)?;
36    let Some(parent_data) = materialized
37        .tables
38        .iter_mut()
39        .find(|table| table.name == parent_table.name)
40    else {
41        return Ok(());
42    };
43    let source_rows = source_data
44        .tables
45        .iter()
46        .find(|table| table.name == derived_from.source_table)
47        .map(|table| table.rows.as_slice())
48        .unwrap_or(&[]);
49
50    for parent_row in &mut parent_data.rows {
51        let parent_key = parent_row
52            .values
53            .get(&derived_from.parent_key)
54            .ok_or_else(|| SoraError::MissingRequiredField {
55                table: parent_table.name.clone(),
56                field: derived_from.parent_key.clone(),
57            })?;
58        let mut child_rows = matching_child_rows(source_rows, derived_from, parent_key)?;
59        if let Some(order_by) = &derived_from.order_by {
60            child_rows.sort_by(|left, right| compare_order_field(left, right, order_by));
61        }
62
63        let values = child_rows
64            .into_iter()
65            .map(|row| derive_child_value(&derived_from.source_table, row, &shape.value))
66            .collect::<Result<Vec<_>>>()?;
67        let value = match shape.cardinality {
68            DerivedFieldCardinality::List => Value::List(values),
69            DerivedFieldCardinality::RequiredOne => {
70                if values.len() != 1 {
71                    return Err(derived_field_row_count_error(
72                        parent_table,
73                        field,
74                        derived_from,
75                        parent_key,
76                        "exactly 1",
77                        values.len(),
78                    ));
79                }
80                values.into_iter().next().expect("checked one value")
81            }
82            DerivedFieldCardinality::OptionalOne => {
83                if values.len() > 1 {
84                    return Err(derived_field_row_count_error(
85                        parent_table,
86                        field,
87                        derived_from,
88                        parent_key,
89                        "at most 1",
90                        values.len(),
91                    ));
92                }
93                values.into_iter().next().unwrap_or(Value::Null)
94            }
95        };
96        parent_row.values.insert(field.name.clone(), value);
97    }
98
99    Ok(())
100}
101
102struct DerivedFieldShape<'a> {
103    cardinality: DerivedFieldCardinality,
104    value: DerivedFieldValue<'a>,
105}
106
107#[derive(Debug, Clone, Copy)]
108enum DerivedFieldCardinality {
109    List,
110    RequiredOne,
111    OptionalOne,
112}
113
114enum DerivedFieldValue<'a> {
115    Struct(&'a StructIr),
116    Field(&'a str),
117}
118
119fn derived_field_shape<'a>(ir: &'a ConfigIr, field: &'a FieldIr) -> Result<DerivedFieldShape<'a>> {
120    let derived_from = field
121        .derived_from
122        .as_ref()
123        .expect("caller filters to derived fields");
124    let (cardinality, value_ty) = match &field.ty {
125        TypeIr::List(element) => (DerivedFieldCardinality::List, element.as_ref()),
126        TypeIr::Optional(element) => (DerivedFieldCardinality::OptionalOne, element.as_ref()),
127        ty => (DerivedFieldCardinality::RequiredOne, ty),
128    };
129
130    if let Some(value_field) = &derived_from.value_field {
131        return Ok(DerivedFieldShape {
132            cardinality,
133            value: DerivedFieldValue::Field(value_field),
134        });
135    }
136
137    let TypeIr::Struct(struct_name) = value_ty else {
138        return Err(SoraError::InvalidSchema(format!(
139            "derived field `{}` must assemble struct values or declare `from.field`",
140            field.name
141        )));
142    };
143
144    let struct_ir = ir
145        .structs
146        .iter()
147        .find(|item| item.name == *struct_name)
148        .ok_or_else(|| {
149            SoraError::InvalidSchema(format!(
150                "derived field `{}` references unknown struct `{struct_name}`",
151                field.name
152            ))
153        })?;
154
155    Ok(DerivedFieldShape {
156        cardinality,
157        value: DerivedFieldValue::Struct(struct_ir),
158    })
159}
160
161fn matching_child_rows<'a>(
162    source_rows: &'a [RowData],
163    derived_from: &DerivedFieldIr,
164    parent_key: &Value,
165) -> Result<Vec<&'a RowData>> {
166    let mut rows = Vec::new();
167    for row in source_rows {
168        let Some(child_key) = row.values.get(&derived_from.child_key) else {
169            return Err(SoraError::MissingRequiredField {
170                table: derived_from.source_table.clone(),
171                field: derived_from.child_key.clone(),
172            });
173        };
174        if stable_key(child_key) == stable_key(parent_key) {
175            rows.push(row);
176        }
177    }
178    Ok(rows)
179}
180
181fn derive_struct_value(source_table: &str, row: &RowData, struct_ir: &StructIr) -> Result<Value> {
182    let mut values = BTreeMap::new();
183    for field in &struct_ir.fields {
184        if let Some(value) = row.values.get(&field.name) {
185            values.insert(field.name.clone(), value.clone());
186        } else if field.is_required() {
187            return Err(SoraError::MissingRequiredField {
188                table: source_table.to_owned(),
189                field: field.name.clone(),
190            });
191        }
192    }
193    Ok(Value::Object(values))
194}
195
196fn derive_child_value(
197    source_table: &str,
198    row: &RowData,
199    value: &DerivedFieldValue<'_>,
200) -> Result<Value> {
201    match value {
202        DerivedFieldValue::Struct(struct_ir) => derive_struct_value(source_table, row, struct_ir),
203        DerivedFieldValue::Field(field) => {
204            row.values
205                .get(*field)
206                .cloned()
207                .ok_or_else(|| SoraError::MissingRequiredField {
208                    table: source_table.to_owned(),
209                    field: (*field).to_owned(),
210                })
211        }
212    }
213}
214
215fn derived_field_row_count_error(
216    parent_table: &TableIr,
217    field: &FieldIr,
218    derived_from: &DerivedFieldIr,
219    parent_key: &Value,
220    expected: &'static str,
221    actual: usize,
222) -> SoraError {
223    SoraError::InvalidSchema(format!(
224        "derived field `{}` in table `{}` expected {} row from `{}` where `{}` = `{}`, but found {}",
225        field.name,
226        parent_table.name,
227        expected,
228        derived_from.source_table,
229        derived_from.child_key,
230        stable_key(parent_key),
231        actual
232    ))
233}
234
235fn compare_order_field(left: &RowData, right: &RowData, order_by: &str) -> Ordering {
236    let left = left.values.get(order_by);
237    let right = right.values.get(order_by);
238    compare_optional_values(left, right)
239}
240
241fn compare_optional_values(left: Option<&Value>, right: Option<&Value>) -> Ordering {
242    match (left, right) {
243        (Some(left), Some(right)) => compare_values(left, right),
244        (None, Some(_)) => Ordering::Less,
245        (Some(_), None) => Ordering::Greater,
246        (None, None) => Ordering::Equal,
247    }
248}
249
250fn compare_values(left: &Value, right: &Value) -> Ordering {
251    match (left, right) {
252        (Value::Bool(left), Value::Bool(right)) => left.cmp(right),
253        (Value::Integer(left), Value::Integer(right)) => left.cmp(right),
254        (Value::Float(left), Value::Float(right)) => {
255            left.partial_cmp(right).unwrap_or(Ordering::Equal)
256        }
257        (Value::String(left), Value::String(right)) => left.cmp(right),
258        _ => stable_key(left).cmp(&stable_key(right)),
259    }
260}
261
262fn stable_key(value: &Value) -> String {
263    match value {
264        Value::Bool(value) => value.to_string(),
265        Value::Integer(value) => value.to_string(),
266        Value::Float(value) => value.to_string(),
267        Value::String(value) => value.clone(),
268        Value::List(_) => "<list>".to_owned(),
269        Value::Object(_) => "<object>".to_owned(),
270        Value::Null => "<null>".to_owned(),
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::model::TableData;
278    use sora_ir::{normalize::normalize_schema, validate::validate_config_ir};
279    use sora_schema::model::SchemaFile;
280
281    #[test]
282    fn materializes_child_rows_into_parent_list_field() {
283        let ir = derived_field_ir();
284        let data = ConfigData {
285            tables: vec![
286                TableData {
287                    name: "Item".to_owned(),
288                    rows: vec![RowData {
289                        values: BTreeMap::from([
290                            ("id".to_owned(), Value::Integer(1001)),
291                            ("name".to_owned(), Value::String("Iron Sword".to_owned())),
292                        ]),
293                    }],
294                },
295                TableData {
296                    name: "ItemReward".to_owned(),
297                    rows: vec![
298                        RowData {
299                            values: BTreeMap::from([
300                                ("item_id".to_owned(), Value::Integer(1001)),
301                                ("seq".to_owned(), Value::Integer(2)),
302                                ("reward_item_id".to_owned(), Value::Integer(3002)),
303                                ("count".to_owned(), Value::Integer(5)),
304                            ]),
305                        },
306                        RowData {
307                            values: BTreeMap::from([
308                                ("item_id".to_owned(), Value::Integer(1001)),
309                                ("seq".to_owned(), Value::Integer(1)),
310                                ("reward_item_id".to_owned(), Value::Integer(3001)),
311                                ("count".to_owned(), Value::Integer(2)),
312                            ]),
313                        },
314                    ],
315                },
316            ],
317        };
318
319        let materialized = materialize_derived_fields(&ir, &data).unwrap();
320        let rewards = &materialized.tables[0].rows[0].values["rewards"];
321
322        assert_eq!(
323            rewards,
324            &Value::List(vec![
325                Value::Object(BTreeMap::from([
326                    ("count".to_owned(), Value::Integer(2)),
327                    ("reward_item_id".to_owned(), Value::Integer(3001)),
328                ])),
329                Value::Object(BTreeMap::from([
330                    ("count".to_owned(), Value::Integer(5)),
331                    ("reward_item_id".to_owned(), Value::Integer(3002)),
332                ])),
333            ])
334        );
335    }
336
337    #[test]
338    fn materializes_single_child_value_field() {
339        let ir = single_value_derived_field_ir("string");
340        let data = ConfigData {
341            tables: vec![
342                TableData {
343                    name: "Item".to_owned(),
344                    rows: vec![RowData {
345                        values: BTreeMap::from([("id".to_owned(), Value::Integer(1001))]),
346                    }],
347                },
348                TableData {
349                    name: "ItemProfile".to_owned(),
350                    rows: vec![RowData {
351                        values: BTreeMap::from([
352                            ("item_id".to_owned(), Value::Integer(1001)),
353                            ("name".to_owned(), Value::String("Iron Sword".to_owned())),
354                            ("notes".to_owned(), Value::String("ignored".to_owned())),
355                        ]),
356                    }],
357                },
358            ],
359        };
360
361        let materialized = materialize_derived_fields(&ir, &data).unwrap();
362
363        assert_eq!(
364            materialized.tables[0].rows[0].values["display_name"],
365            Value::String("Iron Sword".to_owned())
366        );
367    }
368
369    #[test]
370    fn materializes_missing_optional_child_value_as_null() {
371        let ir = single_value_derived_field_ir("optional<string>");
372        let data = ConfigData {
373            tables: vec![
374                TableData {
375                    name: "Item".to_owned(),
376                    rows: vec![RowData {
377                        values: BTreeMap::from([("id".to_owned(), Value::Integer(1001))]),
378                    }],
379                },
380                TableData {
381                    name: "ItemProfile".to_owned(),
382                    rows: Vec::new(),
383                },
384            ],
385        };
386
387        let materialized = materialize_derived_fields(&ir, &data).unwrap();
388
389        assert_eq!(
390            materialized.tables[0].rows[0].values["display_name"],
391            Value::Null
392        );
393    }
394
395    #[test]
396    fn rejects_missing_required_single_child_value() {
397        let ir = single_value_derived_field_ir("string");
398        let data = ConfigData {
399            tables: vec![
400                TableData {
401                    name: "Item".to_owned(),
402                    rows: vec![RowData {
403                        values: BTreeMap::from([("id".to_owned(), Value::Integer(1001))]),
404                    }],
405                },
406                TableData {
407                    name: "ItemProfile".to_owned(),
408                    rows: Vec::new(),
409                },
410            ],
411        };
412
413        let error = materialize_derived_fields(&ir, &data).unwrap_err();
414
415        assert!(
416            error
417                .to_string()
418                .contains("expected exactly 1 row from `ItemProfile`")
419        );
420    }
421
422    #[test]
423    fn rejects_multiple_single_child_values() {
424        let ir = single_value_derived_field_ir("optional<string>");
425        let data = ConfigData {
426            tables: vec![
427                TableData {
428                    name: "Item".to_owned(),
429                    rows: vec![RowData {
430                        values: BTreeMap::from([("id".to_owned(), Value::Integer(1001))]),
431                    }],
432                },
433                TableData {
434                    name: "ItemProfile".to_owned(),
435                    rows: vec![
436                        RowData {
437                            values: BTreeMap::from([
438                                ("item_id".to_owned(), Value::Integer(1001)),
439                                ("name".to_owned(), Value::String("Iron Sword".to_owned())),
440                            ]),
441                        },
442                        RowData {
443                            values: BTreeMap::from([
444                                ("item_id".to_owned(), Value::Integer(1001)),
445                                ("name".to_owned(), Value::String("Sword".to_owned())),
446                            ]),
447                        },
448                    ],
449                },
450            ],
451        };
452
453        let error = materialize_derived_fields(&ir, &data).unwrap_err();
454
455        assert!(
456            error
457                .to_string()
458                .contains("expected at most 1 row from `ItemProfile`")
459        );
460    }
461
462    fn derived_field_ir() -> ConfigIr {
463        let schema: SchemaFile = toml::from_str(
464            r#"
465package = "game_config"
466
467[[structs]]
468name = "Reward"
469
470[[structs.fields]]
471name = "reward_item_id"
472type = "i32"
473
474[[structs.fields]]
475name = "count"
476type = "i32"
477
478[[tables]]
479name = "Item"
480mode = "map"
481key = "id"
482
483[[tables.fields]]
484name = "id"
485type = "i32"
486
487[[tables.fields]]
488name = "name"
489type = "string"
490
491[[tables.fields]]
492name = "rewards"
493type = "list<Reward>"
494from = { table = "ItemReward", parent_key = "id", child_key = "item_id", order_by = "seq" }
495
496[[tables]]
497name = "ItemReward"
498mode = "list"
499
500[[tables.fields]]
501name = "item_id"
502type = "i32"
503
504[[tables.fields]]
505name = "seq"
506type = "i32"
507
508[[tables.fields]]
509name = "reward_item_id"
510type = "i32"
511
512[[tables.fields]]
513name = "count"
514type = "i32"
515"#,
516        )
517        .unwrap();
518        let ir = normalize_schema(schema).unwrap();
519        validate_config_ir(&ir).unwrap();
520        ir
521    }
522
523    fn single_value_derived_field_ir(field_type: &str) -> ConfigIr {
524        let schema: SchemaFile = toml::from_str(&format!(
525            r#"
526package = "game_config"
527
528[[tables]]
529name = "Item"
530mode = "map"
531key = "id"
532
533[[tables.fields]]
534name = "id"
535type = "i32"
536
537[[tables.fields]]
538name = "display_name"
539type = "{field_type}"
540from = {{ table = "ItemProfile", parent_key = "id", child_key = "item_id", field = "name" }}
541
542[[tables]]
543name = "ItemProfile"
544mode = "list"
545
546[[tables.fields]]
547name = "item_id"
548type = "i32"
549
550[[tables.fields]]
551name = "name"
552type = "string"
553
554[[tables.fields]]
555name = "notes"
556type = "string"
557"#
558        ))
559        .unwrap();
560        let ir = normalize_schema(schema).unwrap();
561        validate_config_ir(&ir).unwrap();
562        ir
563    }
564}