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}