1use std::collections::{HashMap, HashSet, VecDeque};
19
20use panproto_gat::Name;
21use panproto_schema::{Edge, Schema};
22use rustc_hash::{FxHashMap, FxHashSet};
23use serde::{Deserialize, Serialize};
24use smallvec::SmallVec;
25
26use crate::error::RestrictError;
27use crate::fan::Fan;
28use crate::metadata::Node;
29use crate::value::Value;
30
31#[derive(Clone, Debug, Default, Serialize, Deserialize)]
36pub struct CompiledMigration {
37 pub surviving_verts: HashSet<Name>,
39 pub surviving_edges: HashSet<Edge>,
41 pub vertex_remap: HashMap<Name, Name>,
43 pub edge_remap: HashMap<Edge, Edge>,
45 pub resolver: HashMap<(Name, Name), Edge>,
47 pub hyper_resolver: HashMap<Name, (Name, HashMap<Name, Name>)>,
49 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54 pub field_transforms: HashMap<Name, Vec<FieldTransform>>,
55 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
71 pub conditional_survival: HashMap<Name, panproto_expr::Expr>,
72 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
80 pub op_term_assignments: HashMap<Name, Vec<TermAssignment>>,
81 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
95 pub expansion_path: HashMap<(Name, Name), Vec<Name>>,
96}
97
98#[derive(Clone, Debug, Serialize, Deserialize)]
105pub enum FieldTransform {
106 RenameField {
108 old_key: String,
110 new_key: String,
112 },
113 DropField {
115 key: String,
117 },
118 AddField {
120 key: String,
122 value: Value,
124 },
125 KeepFields {
127 keys: Vec<String>,
129 },
130 ApplyExpr {
132 key: String,
134 expr: panproto_expr::Expr,
136 inverse: Option<panproto_expr::Expr>,
138 coercion_class: panproto_gat::CoercionClass,
140 },
141 PathTransform {
155 path: Vec<String>,
157 inner: Box<Self>,
159 },
160 ComputeField {
193 target_key: String,
195 expr: panproto_expr::Expr,
197 inverse: Option<panproto_expr::Expr>,
199 coercion_class: panproto_gat::CoercionClass,
201 },
202 Case {
223 branches: Vec<CaseBranch>,
225 },
226 MapReferences {
242 field: String,
244 rename_map: HashMap<String, Option<String>>,
246 },
247}
248
249impl FieldTransform {
250 #[must_use]
257 pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
258 match self {
259 Self::RenameField { .. } => panproto_gat::CoercionClass::Iso,
260 Self::DropField { .. } | Self::KeepFields { .. } => panproto_gat::CoercionClass::Opaque,
261 Self::AddField { .. } | Self::MapReferences { .. } => {
262 panproto_gat::CoercionClass::Retraction
263 }
264 Self::ApplyExpr { coercion_class, .. } | Self::ComputeField { coercion_class, .. } => {
265 *coercion_class
266 }
267 Self::PathTransform { inner, .. } => inner.coercion_class(),
268 Self::Case { branches } => branches
269 .iter()
270 .flat_map(|b| b.transforms.iter())
271 .fold(panproto_gat::CoercionClass::Iso, |acc, t| {
272 acc.compose(t.coercion_class())
273 }),
274 }
275 }
276}
277
278#[derive(Clone, Debug, Serialize, Deserialize)]
283pub struct CaseBranch {
284 pub predicate: panproto_expr::Expr,
286 pub transforms: Vec<FieldTransform>,
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize)]
292pub enum TermScope {
293 Field,
296 Row,
299}
300
301#[derive(Clone, Debug, Serialize, Deserialize)]
303pub struct TermBranch {
304 pub predicate: panproto_expr::Expr,
306 pub assignments: Vec<TermAssignment>,
308}
309
310#[derive(Clone, Debug, Serialize, Deserialize)]
330pub enum TermAssignment {
331 Compute {
333 target: String,
335 scope: TermScope,
337 term: panproto_expr::Expr,
339 inverse: Option<panproto_expr::Expr>,
341 coercion_class: panproto_gat::CoercionClass,
343 },
344 Rename {
346 old: String,
348 new: String,
350 },
351 Drop {
353 key: String,
355 },
356 Default {
358 key: String,
360 value: Value,
362 },
363 Keep {
365 keys: Vec<String>,
367 },
368 MapReferences {
370 field: String,
372 rename_map: HashMap<String, Option<String>>,
374 },
375 AtPath {
377 path: Vec<String>,
379 inner: Box<Self>,
381 },
382 Case {
384 branches: Vec<TermBranch>,
386 },
387}
388
389impl TermAssignment {
390 #[must_use]
392 pub fn from_field_transform(ft: &FieldTransform) -> Self {
393 match ft {
394 FieldTransform::RenameField { old_key, new_key } => Self::Rename {
395 old: old_key.clone(),
396 new: new_key.clone(),
397 },
398 FieldTransform::DropField { key } => Self::Drop { key: key.clone() },
399 FieldTransform::AddField { key, value } => Self::Default {
400 key: key.clone(),
401 value: value.clone(),
402 },
403 FieldTransform::KeepFields { keys } => Self::Keep { keys: keys.clone() },
404 FieldTransform::ApplyExpr {
405 key,
406 expr,
407 inverse,
408 coercion_class,
409 } => Self::Compute {
410 target: key.clone(),
411 scope: TermScope::Field,
412 term: expr.clone(),
413 inverse: inverse.clone(),
414 coercion_class: *coercion_class,
415 },
416 FieldTransform::ComputeField {
417 target_key,
418 expr,
419 inverse,
420 coercion_class,
421 } => Self::Compute {
422 target: target_key.clone(),
423 scope: TermScope::Row,
424 term: expr.clone(),
425 inverse: inverse.clone(),
426 coercion_class: *coercion_class,
427 },
428 FieldTransform::PathTransform { path, inner } => Self::AtPath {
429 path: path.clone(),
430 inner: Box::new(Self::from_field_transform(inner)),
431 },
432 FieldTransform::MapReferences { field, rename_map } => Self::MapReferences {
433 field: field.clone(),
434 rename_map: rename_map.clone(),
435 },
436 FieldTransform::Case { branches } => Self::Case {
437 branches: branches
438 .iter()
439 .map(|b| TermBranch {
440 predicate: b.predicate.clone(),
441 assignments: b
442 .transforms
443 .iter()
444 .map(Self::from_field_transform)
445 .collect(),
446 })
447 .collect(),
448 },
449 }
450 }
451
452 #[must_use]
454 pub fn to_field_transform(&self) -> FieldTransform {
455 match self {
456 Self::Rename { old, new } => FieldTransform::RenameField {
457 old_key: old.clone(),
458 new_key: new.clone(),
459 },
460 Self::Drop { key } => FieldTransform::DropField { key: key.clone() },
461 Self::Default { key, value } => FieldTransform::AddField {
462 key: key.clone(),
463 value: value.clone(),
464 },
465 Self::Keep { keys } => FieldTransform::KeepFields { keys: keys.clone() },
466 Self::Compute {
467 target,
468 scope: TermScope::Field,
469 term,
470 inverse,
471 coercion_class,
472 } => FieldTransform::ApplyExpr {
473 key: target.clone(),
474 expr: term.clone(),
475 inverse: inverse.clone(),
476 coercion_class: *coercion_class,
477 },
478 Self::Compute {
479 target,
480 scope: TermScope::Row,
481 term,
482 inverse,
483 coercion_class,
484 } => FieldTransform::ComputeField {
485 target_key: target.clone(),
486 expr: term.clone(),
487 inverse: inverse.clone(),
488 coercion_class: *coercion_class,
489 },
490 Self::MapReferences { field, rename_map } => FieldTransform::MapReferences {
491 field: field.clone(),
492 rename_map: rename_map.clone(),
493 },
494 Self::AtPath { path, inner } => FieldTransform::PathTransform {
495 path: path.clone(),
496 inner: Box::new(inner.to_field_transform()),
497 },
498 Self::Case { branches } => FieldTransform::Case {
499 branches: branches
500 .iter()
501 .map(|b| CaseBranch {
502 predicate: b.predicate.clone(),
503 transforms: b.assignments.iter().map(Self::to_field_transform).collect(),
504 })
505 .collect(),
506 },
507 }
508 }
509
510 #[must_use]
513 pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
514 self.to_field_transform().coercion_class()
515 }
516}
517
518pub fn apply_term_assignments_to_row(
533 row: &mut HashMap<String, Value>,
534 assignments: &[TermAssignment],
535) -> Result<(), RestrictError> {
536 if assignments.is_empty() {
537 return Ok(());
538 }
539 let transforms: Vec<FieldTransform> = assignments
540 .iter()
541 .map(TermAssignment::to_field_transform)
542 .collect();
543 let mut node = Node::new(0, "");
544 node.extra_fields = std::mem::take(row);
545 let outcome = apply_field_transforms(&mut node, &transforms, &TransformContext::detached());
546 *row = node.extra_fields;
549 outcome
550}
551
552impl CompiledMigration {
553 #[must_use]
568 pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
569 let from_fields = self
570 .field_transforms
571 .values()
572 .flat_map(|ts| ts.iter())
573 .fold(panproto_gat::CoercionClass::Iso, |acc, t| {
574 acc.compose(t.coercion_class())
575 });
576 self.op_term_assignments
577 .values()
578 .flat_map(|ts| ts.iter())
579 .fold(from_fields, |acc, t| acc.compose(t.coercion_class()))
580 }
581
582 #[must_use]
590 pub fn value_transforms(&self, anchor: &Name) -> Vec<FieldTransform> {
591 let mut out: Vec<FieldTransform> = self
592 .field_transforms
593 .get(anchor)
594 .cloned()
595 .unwrap_or_default();
596 if let Some(assignments) = self.op_term_assignments.get(anchor) {
597 out.extend(assignments.iter().map(TermAssignment::to_field_transform));
598 }
599 out
600 }
601
602 pub fn add_field_rename(&mut self, vertex: &str, old_key: &str, new_key: &str) {
607 self.field_transforms
608 .entry(Name::from(vertex))
609 .or_default()
610 .push(FieldTransform::RenameField {
611 old_key: old_key.to_owned(),
612 new_key: new_key.to_owned(),
613 });
614 }
615
616 pub fn add_field_drop(&mut self, vertex: &str, key: &str) {
620 self.field_transforms
621 .entry(Name::from(vertex))
622 .or_default()
623 .push(FieldTransform::DropField {
624 key: key.to_owned(),
625 });
626 }
627
628 pub fn add_field_default(&mut self, vertex: &str, key: &str, value: Value) {
633 self.field_transforms
634 .entry(Name::from(vertex))
635 .or_default()
636 .push(FieldTransform::AddField {
637 key: key.to_owned(),
638 value,
639 });
640 }
641
642 pub fn add_field_keep(&mut self, vertex: &str, keys: &[&str]) {
647 self.field_transforms
648 .entry(Name::from(vertex))
649 .or_default()
650 .push(FieldTransform::KeepFields {
651 keys: keys.iter().map(|k| (*k).to_owned()).collect(),
652 });
653 }
654
655 pub fn add_field_expr(&mut self, vertex: &str, key: &str, expr: panproto_expr::Expr) {
661 self.field_transforms
662 .entry(Name::from(vertex))
663 .or_default()
664 .push(FieldTransform::ApplyExpr {
665 key: key.to_owned(),
666 expr,
667 inverse: None,
668 coercion_class: panproto_gat::CoercionClass::Opaque,
669 });
670 }
671
672 pub fn add_path_transform(&mut self, vertex: &str, path: &[&str], inner: FieldTransform) {
678 self.field_transforms
679 .entry(Name::from(vertex))
680 .or_default()
681 .push(FieldTransform::PathTransform {
682 path: path.iter().map(|s| (*s).to_owned()).collect(),
683 inner: Box::new(inner),
684 });
685 }
686
687 pub fn add_computed_field(
692 &mut self,
693 vertex: &str,
694 target_key: &str,
695 expr: panproto_expr::Expr,
696 ) {
697 self.field_transforms
698 .entry(Name::from(vertex))
699 .or_default()
700 .push(FieldTransform::ComputeField {
701 target_key: target_key.to_owned(),
702 expr,
703 inverse: None,
704 coercion_class: panproto_gat::CoercionClass::Opaque,
705 });
706 }
707
708 pub fn add_conditional_survival(&mut self, vertex: &str, predicate: panproto_expr::Expr) {
713 self.conditional_survival
714 .entry(Name::from(vertex))
715 .or_insert(predicate);
716 }
717
718 pub fn add_map_references(
723 &mut self,
724 vertex: &str,
725 field: &str,
726 rename_map: HashMap<String, Option<String>>,
727 ) {
728 self.field_transforms
729 .entry(Name::from(vertex))
730 .or_default()
731 .push(FieldTransform::MapReferences {
732 field: field.to_owned(),
733 rename_map,
734 });
735 }
736
737 pub fn add_case_transform(&mut self, vertex: &str, branches: Vec<CaseBranch>) {
743 self.field_transforms
744 .entry(Name::from(vertex))
745 .or_default()
746 .push(FieldTransform::Case { branches });
747 }
748}
749
750#[derive(Clone, Debug, Serialize, Deserialize)]
756pub struct WInstance {
757 pub nodes: HashMap<u32, Node>,
759 pub arcs: Vec<(u32, u32, Edge)>,
761 pub fans: Vec<Fan>,
763 pub root: u32,
765 pub schema_root: Name,
767 pub parent_map: HashMap<u32, u32>,
769 pub children_map: HashMap<u32, SmallVec<u32, 4>>,
771}
772
773impl WInstance {
774 #[must_use]
776 pub fn new(
777 nodes: HashMap<u32, Node>,
778 arcs: Vec<(u32, u32, Edge)>,
779 fans: Vec<Fan>,
780 root: u32,
781 schema_root: Name,
782 ) -> Self {
783 let mut parent_map = HashMap::with_capacity(arcs.len());
784 let mut children_map: HashMap<u32, SmallVec<u32, 4>> = HashMap::new();
785 for &(parent, child, _) in &arcs {
786 parent_map.insert(child, parent);
787 children_map.entry(parent).or_default().push(child);
788 }
789 Self {
790 nodes,
791 arcs,
792 fans,
793 root,
794 schema_root,
795 parent_map,
796 children_map,
797 }
798 }
799
800 #[inline]
802 #[must_use]
803 pub fn node_count(&self) -> usize {
804 self.nodes.len()
805 }
806
807 #[inline]
809 #[must_use]
810 pub fn arc_count(&self) -> usize {
811 self.arcs.len()
812 }
813
814 #[inline]
816 #[must_use]
817 pub fn node(&self, id: u32) -> Option<&Node> {
818 self.nodes.get(&id)
819 }
820
821 #[inline]
823 #[must_use]
824 pub fn children(&self, id: u32) -> &[u32] {
825 self.children_map.get(&id).map_or(&[], SmallVec::as_slice)
826 }
827
828 #[inline]
830 #[must_use]
831 pub fn parent(&self, id: u32) -> Option<u32> {
832 self.parent_map.get(&id).copied()
833 }
834}
835
836#[must_use]
842pub fn anchor_surviving(instance: &WInstance, surviving_verts: &HashSet<Name>) -> HashSet<u32> {
843 instance
844 .nodes
845 .iter()
846 .filter(|(_, node)| surviving_verts.contains(&node.anchor))
847 .map(|(&id, _)| id)
848 .collect()
849}
850
851#[must_use]
866pub fn ancestor_contraction(instance: &WInstance, surviving: &HashSet<u32>) -> HashMap<u32, u32> {
867 let mut cache: FxHashMap<u32, u32> = FxHashMap::default();
868 let mut ancestors = HashMap::new();
869
870 for &node_id in surviving {
871 if node_id == instance.root {
872 continue;
873 }
874
875 if let Some(&cached) = cache.get(&node_id) {
877 ancestors.insert(node_id, cached);
878 continue;
879 }
880
881 let mut path = Vec::new();
883 let mut current = node_id;
884 let mut found_ancestor = None;
885
886 while let Some(parent) = instance.parent(current) {
887 if let Some(&cached) = cache.get(&parent) {
888 found_ancestor = Some(cached);
889 break;
890 }
891 if surviving.contains(&parent) {
892 found_ancestor = Some(parent);
893 break;
894 }
895 path.push(parent);
896 current = parent;
897 }
898
899 if let Some(ancestor) = found_ancestor {
901 ancestors.insert(node_id, ancestor);
902 cache.insert(node_id, ancestor);
903 for &intermediate in &path {
904 cache.insert(intermediate, ancestor);
905 }
906 }
907 }
908 ancestors
909}
910
911pub fn resolve_edge(
926 tgt_schema: &Schema,
927 resolver: &HashMap<(Name, Name), Edge>,
928 src_v: &str,
929 tgt_v: &str,
930) -> Result<Edge, RestrictError> {
931 for ((k_src, k_tgt), edge) in resolver {
933 if k_src == src_v && k_tgt == tgt_v {
934 return Ok(edge.clone());
935 }
936 }
937
938 let candidates = tgt_schema.edges_between(src_v, tgt_v);
940 match candidates.len() {
941 0 => Err(RestrictError::NoEdgeFound {
942 src: src_v.to_string(),
943 tgt: tgt_v.to_string(),
944 }),
945 1 => Ok(candidates[0].clone()),
946 n => Err(RestrictError::AmbiguousEdge {
947 src: src_v.to_string(),
948 tgt: tgt_v.to_string(),
949 count: n,
950 }),
951 }
952}
953
954pub fn reconstruct_fans(
965 instance: &WInstance,
966 surviving: &FxHashSet<u32>,
967 _ancestors: &FxHashMap<u32, u32>,
968 migration: &CompiledMigration,
969 _tgt_schema: &Schema,
970) -> Result<Vec<Fan>, RestrictError> {
971 let mut result = Vec::new();
972
973 for fan in &instance.fans {
974 if !surviving.contains(&fan.parent) {
975 continue;
976 }
977
978 let surviving_children: HashMap<String, u32> = fan
979 .children
980 .iter()
981 .filter(|(_, node_id)| surviving.contains(node_id))
982 .map(|(label, node_id)| (label.clone(), *node_id))
983 .collect();
984
985 if surviving_children.is_empty() {
986 continue;
987 }
988
989 if let Some((new_he_id, label_map)) =
990 migration.hyper_resolver.get(fan.hyper_edge_id.as_str())
991 {
992 let mut new_children = HashMap::new();
993 for (old_label, &node_id) in &surviving_children {
994 let new_label = label_map
995 .get(old_label.as_str())
996 .map_or_else(|| old_label.clone(), std::string::ToString::to_string);
997 new_children.insert(new_label, node_id);
998 }
999 result.push(Fan {
1000 hyper_edge_id: new_he_id.to_string(),
1001 parent: fan.parent,
1002 children: new_children,
1003 });
1004 } else {
1005 result.push(Fan {
1006 hyper_edge_id: fan.hyper_edge_id.clone(),
1007 parent: fan.parent,
1008 children: surviving_children,
1009 });
1010 }
1011 }
1012
1013 Ok(result)
1014}
1015
1016pub fn wtype_restrict(
1034 instance: &WInstance,
1035 src_schema: &Schema,
1036 tgt_schema: &Schema,
1037 migration: &CompiledMigration,
1038) -> Result<WInstance, RestrictError> {
1039 let root_node = instance
1041 .nodes
1042 .get(&instance.root)
1043 .ok_or(RestrictError::RootPruned)?;
1044 let root_target_anchor = migration
1045 .vertex_remap
1046 .get(&root_node.anchor)
1047 .unwrap_or(&root_node.anchor);
1048 if !migration.surviving_verts.contains(root_target_anchor) {
1049 return Err(RestrictError::RootPruned);
1050 }
1051
1052 let conditional_fail = precompute_conditional_fail(instance, migration);
1053
1054 let mut new_nodes: HashMap<u32, Node> = HashMap::new();
1067 let mut new_arcs: Vec<(u32, u32, Edge)> = Vec::new();
1068 let mut surviving_set: FxHashSet<u32> = FxHashSet::default();
1069
1070 let mut next_synth_id: u32 = instance
1074 .nodes
1075 .keys()
1076 .copied()
1077 .max()
1078 .map_or(0, |m| m.saturating_add(1));
1079
1080 let mut queue: VecDeque<(u32, Option<u32>)> = VecDeque::new();
1082
1083 let root_node_cloned = prepare_root_node(root_node, migration, instance, src_schema)?;
1085 new_nodes.insert(instance.root, root_node_cloned);
1086 surviving_set.insert(instance.root);
1087 queue.push_back((instance.root, None));
1088
1089 while let Some((current_id, ancestor_id)) = queue.pop_front() {
1090 let current_survives = surviving_set.contains(¤t_id);
1091 let child_ancestor = if current_survives {
1094 Some(current_id)
1095 } else {
1096 ancestor_id
1097 };
1098
1099 for &child_id in instance.children(current_id) {
1100 let Some(child_node) = instance.nodes.get(&child_id) else {
1101 continue;
1102 };
1103
1104 let target_anchor = migration
1107 .vertex_remap
1108 .get(&child_node.anchor)
1109 .unwrap_or(&child_node.anchor);
1110 if migration.surviving_verts.contains(target_anchor)
1111 && !conditional_fail.contains(&child_id)
1112 {
1113 surviving_set.insert(child_id);
1115 let mut new_node = child_node.clone();
1116 if let Some(remapped) = migration.vertex_remap.get(&child_node.anchor) {
1117 new_node.anchor.clone_from(remapped);
1118 }
1119 let transforms = migration.value_transforms(&child_node.anchor);
1123 if !transforms.is_empty() {
1124 let ctx =
1125 TransformContext::new(Some(src_schema), instance, child_id, &transforms);
1126 apply_field_transforms(&mut new_node, &transforms, &ctx)?;
1127 }
1128 new_nodes.insert(child_id, new_node.clone());
1129
1130 if let Some(anc_id) = child_ancestor {
1142 connect_ancestor_to_child(
1143 anc_id,
1144 child_id,
1145 &new_node.anchor,
1146 &mut new_nodes,
1147 &mut new_arcs,
1148 &mut surviving_set,
1149 &mut next_synth_id,
1150 migration,
1151 tgt_schema,
1152 )?;
1153 }
1154 }
1155
1156 queue.push_back((child_id, child_ancestor));
1159 }
1160 }
1161
1162 let fused_surviving = &surviving_set;
1164 let empty_ancestors = FxHashMap::default();
1165 let new_fans = reconstruct_fans(
1166 instance,
1167 fused_surviving,
1168 &empty_ancestors,
1169 migration,
1170 tgt_schema,
1171 )?;
1172
1173 let new_schema_root = migration
1174 .vertex_remap
1175 .get(&instance.schema_root)
1176 .cloned()
1177 .unwrap_or_else(|| instance.schema_root.clone());
1178
1179 Ok(WInstance::new(
1180 new_nodes,
1181 new_arcs,
1182 new_fans,
1183 instance.root,
1184 new_schema_root,
1185 ))
1186}
1187
1188fn precompute_conditional_fail(
1195 instance: &WInstance,
1196 migration: &CompiledMigration,
1197) -> FxHashSet<u32> {
1198 if migration.conditional_survival.is_empty() {
1199 return FxHashSet::default();
1200 }
1201 instance
1202 .nodes
1203 .iter()
1204 .filter_map(|(&id, node)| {
1205 let pred = migration.conditional_survival.get(&node.anchor)?;
1206 let env = build_env_from_extra_fields(&node.extra_fields);
1207 let config = panproto_expr::EvalConfig::default();
1208 matches!(
1209 panproto_expr::eval(pred, &env, &config),
1210 Ok(panproto_expr::Literal::Bool(false))
1211 )
1212 .then_some(id)
1213 })
1214 .collect()
1215}
1216
1217#[allow(clippy::too_many_arguments)]
1221fn connect_ancestor_to_child(
1222 anc_id: u32,
1223 child_id: u32,
1224 child_anchor: &Name,
1225 new_nodes: &mut HashMap<u32, Node>,
1226 new_arcs: &mut Vec<(u32, u32, Edge)>,
1227 surviving_set: &mut FxHashSet<u32>,
1228 next_synth_id: &mut u32,
1229 migration: &CompiledMigration,
1230 tgt_schema: &Schema,
1231) -> Result<(), RestrictError> {
1232 let anc_anchor = new_nodes
1233 .get(&anc_id)
1234 .ok_or(RestrictError::RootPruned)?
1235 .anchor
1236 .clone();
1237 let child_anchor = child_anchor.clone();
1238 match resolve_edge(tgt_schema, &migration.resolver, &anc_anchor, &child_anchor) {
1239 Ok(edge) => {
1240 new_arcs.push((anc_id, child_id, edge));
1241 Ok(())
1242 }
1243 Err(restrict_err) => {
1244 let Some(intermediates) = migration
1245 .expansion_path
1246 .get(&(anc_anchor.clone(), child_anchor.clone()))
1247 else {
1248 return Err(restrict_err);
1249 };
1250 let mut prev_id = anc_id;
1253 let mut prev_anchor = anc_anchor;
1254 for intermediate_anchor in intermediates {
1255 let synth_id = *next_synth_id;
1256 *next_synth_id = next_synth_id.saturating_add(1);
1257 let synth_node = Node::new(synth_id, intermediate_anchor.clone());
1258 new_nodes.insert(synth_id, synth_node);
1259 surviving_set.insert(synth_id);
1260 let edge = resolve_edge(
1261 tgt_schema,
1262 &migration.resolver,
1263 &prev_anchor,
1264 intermediate_anchor,
1265 )?;
1266 new_arcs.push((prev_id, synth_id, edge));
1267 prev_id = synth_id;
1268 prev_anchor = intermediate_anchor.clone();
1269 }
1270 let final_edge =
1271 resolve_edge(tgt_schema, &migration.resolver, &prev_anchor, &child_anchor)?;
1272 new_arcs.push((prev_id, child_id, final_edge));
1273 Ok(())
1274 }
1275 }
1276}
1277
1278pub fn apply_field_transforms(
1316 node: &mut Node,
1317 transforms: &[FieldTransform],
1318 ctx: &TransformContext<'_>,
1319) -> Result<(), RestrictError> {
1320 let child_scalars = &ctx.child_values;
1321 for transform in transforms {
1322 match transform {
1323 FieldTransform::RenameField { old_key, new_key } => {
1324 if let Some(val) = node.extra_fields.remove(old_key) {
1325 node.extra_fields.insert(new_key.clone(), val);
1326 }
1327 }
1328 FieldTransform::DropField { key } => {
1329 node.extra_fields.remove(key);
1330 }
1331 FieldTransform::AddField { key, value } => {
1332 node.extra_fields
1333 .entry(key.clone())
1334 .or_insert_with(|| value.clone());
1335 }
1336 FieldTransform::KeepFields { keys } => {
1337 node.extra_fields.retain(|k, _| keys.contains(k));
1338 }
1339 FieldTransform::ApplyExpr { key, expr, .. } => {
1340 apply_expr_transform(node, key, expr, child_scalars, ctx)?;
1341 }
1342 FieldTransform::ComputeField {
1343 target_key, expr, ..
1344 } => {
1345 let env = build_env_with_children(&node.extra_fields, child_scalars);
1346 let config = panproto_expr::EvalConfig::default();
1347 let result = ctx.eval(expr, &env, &config).map_err(|source| {
1348 RestrictError::FieldTransformFailed {
1349 key: target_key.clone(),
1350 source,
1351 }
1352 })?;
1353 node.extra_fields
1354 .insert(target_key.clone(), expr_literal_to_value(&result));
1355 }
1356 FieldTransform::PathTransform { path, inner } => {
1357 if path.is_empty() {
1358 apply_field_transforms(
1361 node,
1362 std::slice::from_ref(inner),
1363 &ctx.with_child_values(HashMap::new()),
1364 )?;
1365 } else {
1366 apply_path_transform(node, path, inner, ctx)?;
1367 }
1368 }
1369 FieldTransform::MapReferences { field, rename_map } => {
1370 apply_map_references(node, field, rename_map);
1371 }
1372 FieldTransform::Case { branches } => {
1373 let env = build_env_with_children(&node.extra_fields, child_scalars);
1377 let config = panproto_expr::EvalConfig::default();
1378 for (index, branch) in branches.iter().enumerate() {
1379 let result = ctx
1380 .eval(&branch.predicate, &env, &config)
1381 .map_err(|source| RestrictError::FieldTransformFailed {
1382 key: format!("<case branch {index}>"),
1383 source,
1384 })?;
1385 if matches!(result, panproto_expr::Literal::Bool(true)) {
1386 apply_field_transforms(node, &branch.transforms, ctx)?;
1387 break;
1388 }
1389 }
1390 }
1391 }
1392 }
1393 Ok(())
1394}
1395
1396fn apply_expr_transform(
1410 node: &mut Node,
1411 key: &str,
1412 expr: &panproto_expr::Expr,
1413 child_scalars: &HashMap<String, Value>,
1414 ctx: &TransformContext<'_>,
1415) -> Result<(), RestrictError> {
1416 let config = panproto_expr::EvalConfig::default();
1417 let failed = |source| RestrictError::FieldTransformFailed {
1418 key: key.to_string(),
1419 source,
1420 };
1421
1422 if key == "__value__" {
1423 if let Some(crate::value::FieldPresence::Present(val)) = &node.value {
1424 let input = value_to_expr_literal(val);
1425 let env = panproto_expr::Env::new()
1426 .extend(std::sync::Arc::from("v"), input.clone())
1427 .extend(std::sync::Arc::from("__value__"), input);
1428 let result = ctx.eval(expr, &env, &config).map_err(failed)?;
1429 node.value = Some(crate::value::FieldPresence::Present(expr_literal_to_value(
1430 &result,
1431 )));
1432 }
1433 return Ok(());
1434 }
1435
1436 if let Some(val) = node
1437 .extra_fields
1438 .get(key)
1439 .or_else(|| child_scalars.get(key))
1440 {
1441 let input = value_to_expr_literal(val);
1442 let env = panproto_expr::Env::new().extend(std::sync::Arc::from(key), input);
1443 let result = ctx.eval(expr, &env, &config).map_err(failed)?;
1444 node.extra_fields
1445 .insert(key.to_string(), expr_literal_to_value(&result));
1446 }
1447 Ok(())
1448}
1449
1450fn apply_path_transform(
1453 node: &mut Node,
1454 path: &[String],
1455 inner: &FieldTransform,
1456 ctx: &TransformContext<'_>,
1457) -> Result<(), RestrictError> {
1458 let first = &path[0];
1459 let Some(Value::Unknown(map)) = node.extra_fields.get_mut(first) else {
1460 return Ok(());
1461 };
1462
1463 let mut temp_node = Node::new(0, "");
1466 temp_node.extra_fields = std::mem::take(map);
1467
1468 let outcome = if path.len() == 1 {
1469 apply_field_transforms(
1473 &mut temp_node,
1474 std::slice::from_ref(inner),
1475 &ctx.with_child_values(HashMap::new()),
1476 )
1477 } else {
1478 apply_path_transform(&mut temp_node, &path[1..], inner, ctx)
1479 };
1480
1481 if let Some(Value::Unknown(slot)) = node.extra_fields.get_mut(first) {
1485 *slot = temp_node.extra_fields;
1486 }
1487 outcome
1488}
1489
1490fn apply_map_references(
1498 node: &mut Node,
1499 field: &str,
1500 rename_map: &HashMap<String, Option<String>>,
1501) {
1502 if let Some(val) = node.extra_fields.get_mut(field) {
1503 match val {
1504 Value::Str(s) => {
1505 if let Some(replacement) = rename_map.get(s.as_str()) {
1506 match replacement {
1507 Some(new_name) => *s = new_name.clone(),
1508 None => {
1509 node.extra_fields.remove(field);
1510 }
1511 }
1512 }
1513 }
1514 Value::List(items) => {
1515 let mut new_items = Vec::with_capacity(items.len());
1520 for item in items.iter() {
1521 match item {
1522 Value::Str(s) => match rename_map.get(s.as_str()) {
1523 Some(Some(new_name)) => {
1524 new_items.push(Value::Str(new_name.clone()));
1525 }
1526 Some(None) => {} None => new_items.push(Value::Str(s.clone())),
1528 },
1529 other => new_items.push(other.clone()),
1530 }
1531 }
1532 *items = new_items;
1533 }
1534 _ => {}
1535 }
1536 }
1537}
1538
1539#[derive(Debug, Clone)]
1553pub struct TransformContext<'a> {
1554 pub child_values: HashMap<String, Value>,
1556 pub instance: Option<(&'a WInstance, u32)>,
1558}
1559
1560impl Default for TransformContext<'_> {
1561 fn default() -> Self {
1562 Self::detached()
1563 }
1564}
1565
1566impl<'a> TransformContext<'a> {
1567 #[must_use]
1570 pub fn detached() -> Self {
1571 Self {
1572 child_values: HashMap::new(),
1573 instance: None,
1574 }
1575 }
1576
1577 #[must_use]
1580 pub fn new(
1581 schema: Option<&Schema>,
1582 instance: &'a WInstance,
1583 node_id: u32,
1584 transforms: &[FieldTransform],
1585 ) -> Self {
1586 let wanted = referenced_names(transforms);
1587 let demand = if wanted.contains("self") {
1590 None
1591 } else {
1592 Some(&wanted)
1593 };
1594 Self {
1595 child_values: collect_child_values(schema, instance, node_id, demand),
1596 instance: Some((instance, node_id)),
1597 }
1598 }
1599
1600 #[must_use]
1602 pub const fn from_child_values(child_values: HashMap<String, Value>) -> Self {
1603 Self {
1604 child_values,
1605 instance: None,
1606 }
1607 }
1608
1609 #[must_use]
1611 pub const fn with_child_values(&self, child_values: HashMap<String, Value>) -> Self {
1612 Self {
1613 child_values,
1614 instance: self.instance,
1615 }
1616 }
1617
1618 pub fn eval(
1625 &self,
1626 expr: &panproto_expr::Expr,
1627 env: &panproto_expr::Env,
1628 config: &panproto_expr::EvalConfig,
1629 ) -> Result<panproto_expr::Literal, panproto_expr::ExprError> {
1630 match self.instance {
1631 Some((instance, node_id)) => {
1632 crate::instance_env::eval_with_instance(expr, env, config, instance, Some(node_id))
1633 }
1634 None => panproto_expr::eval(expr, env, config),
1635 }
1636 }
1637}
1638
1639#[must_use]
1645pub fn referenced_names(transforms: &[FieldTransform]) -> std::collections::HashSet<String> {
1646 let mut names = std::collections::HashSet::new();
1647 collect_referenced_names(transforms, &mut names);
1648 names
1649}
1650
1651fn collect_referenced_names(
1652 transforms: &[FieldTransform],
1653 names: &mut std::collections::HashSet<String>,
1654) {
1655 let add = |expr: &panproto_expr::Expr, names: &mut std::collections::HashSet<String>| {
1656 for var in panproto_expr::free_vars(expr) {
1657 names.insert(var.to_string());
1658 }
1659 };
1660 for transform in transforms {
1661 match transform {
1662 FieldTransform::ComputeField { expr, inverse, .. }
1663 | FieldTransform::ApplyExpr { expr, inverse, .. } => {
1664 add(expr, names);
1665 if let Some(inv) = inverse {
1666 add(inv, names);
1667 }
1668 }
1669 FieldTransform::Case { branches } => {
1670 for branch in branches {
1671 add(&branch.predicate, names);
1672 collect_referenced_names(&branch.transforms, names);
1673 }
1674 }
1675 FieldTransform::PathTransform { inner, .. } => {
1676 collect_referenced_names(std::slice::from_ref(inner), names);
1677 }
1678 _ => {}
1679 }
1680 }
1681}
1682
1683#[must_use]
1702pub fn collect_scalar_child_values(instance: &WInstance, node_id: u32) -> HashMap<String, Value> {
1703 let mut result = HashMap::new();
1704 for &(parent, child, ref edge) in &instance.arcs {
1705 if parent != node_id {
1706 continue;
1707 }
1708 let Some(child_node) = instance.nodes.get(&child) else {
1709 continue;
1710 };
1711 if let Some(crate::value::FieldPresence::Present(val)) = &child_node.value {
1712 let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
1713 result.insert(field_name.to_string(), val.clone());
1714 }
1715 }
1716 result
1717}
1718
1719#[must_use]
1740pub fn collect_child_values(
1741 schema: Option<&Schema>,
1742 instance: &WInstance,
1743 node_id: u32,
1744 wanted: Option<&std::collections::HashSet<String>>,
1745) -> HashMap<String, Value> {
1746 let mut result = HashMap::new();
1747 for &(parent, child, ref edge) in &instance.arcs {
1748 if parent != node_id {
1749 continue;
1750 }
1751 let Some(child_node) = instance.nodes.get(&child) else {
1752 continue;
1753 };
1754 let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
1755 if let Some(crate::value::FieldPresence::Present(val)) = &child_node.value {
1756 result.insert(field_name.to_string(), val.clone());
1757 } else if wanted.is_none_or(|w| w.contains(field_name)) {
1758 result.insert(
1759 field_name.to_string(),
1760 node_to_value(schema, instance, child),
1761 );
1762 }
1763 }
1764 result
1765}
1766
1767#[must_use]
1777pub fn node_to_value(schema: Option<&Schema>, instance: &WInstance, node_id: u32) -> Value {
1778 let Some(node) = instance.nodes.get(&node_id) else {
1779 return Value::Null;
1780 };
1781
1782 if let Some(presence) = &node.value {
1783 return match presence {
1784 crate::value::FieldPresence::Present(val) => val.clone(),
1785 crate::value::FieldPresence::Null | crate::value::FieldPresence::Absent => Value::Null,
1786 };
1787 }
1788
1789 if is_collection_node(schema, instance, node) {
1790 let items = instance
1791 .children(node_id)
1792 .iter()
1793 .map(|&child| node_to_value(schema, instance, child))
1794 .collect();
1795 return Value::List(items);
1796 }
1797
1798 let mut fields = HashMap::new();
1799 for &(parent, child, ref edge) in &instance.arcs {
1800 if parent != node_id {
1801 continue;
1802 }
1803 let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
1804 fields.insert(
1805 field_name.to_string(),
1806 node_to_value(schema, instance, child),
1807 );
1808 }
1809 for (key, val) in &node.extra_fields {
1810 fields.insert(key.clone(), val.clone());
1811 }
1812 Value::Unknown(fields)
1813}
1814
1815fn is_collection_node(schema: Option<&Schema>, instance: &WInstance, node: &Node) -> bool {
1824 if node.is_list() {
1825 return true;
1826 }
1827
1828 let mut signature: Option<(panproto_gat::Name, Option<panproto_gat::Name>)> = None;
1829 let mut count = 0_usize;
1830 let mut uniform = true;
1831 for &(parent, _, ref edge) in &instance.arcs {
1832 if parent != node.id {
1833 continue;
1834 }
1835 let key = (edge.kind.clone(), edge.name.clone());
1836 match &signature {
1837 Some(existing) if existing != &key => {
1838 uniform = false;
1839 break;
1840 }
1841 Some(_) => {}
1842 None => signature = Some(key),
1843 }
1844 count += 1;
1845 }
1846 if uniform && count >= 2 {
1847 return true;
1848 }
1849
1850 let Some(schema) = schema else {
1851 return false;
1852 };
1853 let outgoing = schema.outgoing_edges(&node.anchor);
1854 let via_schema = !outgoing.is_empty() && outgoing.iter().all(|e| e.name.is_none());
1855 let object_only = !node.extra_fields.is_empty() || node.discriminator.is_some();
1856 via_schema && !object_only
1857}
1858
1859#[must_use]
1882pub fn build_env_with_children(
1883 fields: &HashMap<String, Value>,
1884 child_scalars: &HashMap<String, Value>,
1885) -> panproto_expr::Env {
1886 let mut combined = child_scalars.clone();
1889 for (key, val) in fields {
1890 combined.insert(key.clone(), val.clone());
1891 }
1892 let this = Value::Unknown(combined.clone());
1893 let env = panproto_expr::Env::new()
1894 .extend(std::sync::Arc::from("self"), value_to_expr_literal(&this));
1895 extend_env_from_extra_fields(env, &combined)
1896}
1897
1898#[must_use]
1916pub fn build_env_from_extra_fields(fields: &HashMap<String, Value>) -> panproto_expr::Env {
1917 extend_env_from_extra_fields(panproto_expr::Env::new(), fields)
1918}
1919
1920#[must_use]
1924pub fn extend_env_from_extra_fields(
1925 base: panproto_expr::Env,
1926 fields: &HashMap<String, Value>,
1927) -> panproto_expr::Env {
1928 let mut env = base;
1929 for (key, val) in fields {
1930 let lit = value_to_expr_literal(val);
1931 env = env.extend(std::sync::Arc::from(key.as_str()), lit.clone());
1933 if key != "attrs" && key != "name" && key != "$type" && key != "parents" {
1935 let qualified = format!("attrs.{key}");
1936 env = env.extend(std::sync::Arc::from(qualified.as_str()), lit);
1937 }
1938 }
1939 if let Some(Value::Unknown(attrs)) = fields.get("attrs") {
1941 for (key, val) in attrs {
1942 let lit = value_to_expr_literal(val);
1943 let qualified = format!("attrs.{key}");
1944 env = env.extend(std::sync::Arc::from(qualified.as_str()), lit.clone());
1945 if !fields.contains_key(key) {
1947 env = env.extend(std::sync::Arc::from(key.as_str()), lit);
1948 }
1949 }
1950 }
1951 env
1952}
1953
1954#[must_use]
1979pub fn value_to_expr_literal(val: &Value) -> panproto_expr::Literal {
1980 match val {
1981 Value::Bool(b) => panproto_expr::Literal::Bool(*b),
1982 Value::Int(i) => panproto_expr::Literal::Int(*i),
1983 Value::Float(f) => panproto_expr::Literal::Float(*f),
1984 Value::Str(s) => panproto_expr::Literal::Str(s.clone()),
1985 Value::Bytes(b) => panproto_expr::Literal::Bytes(b.clone()),
1986 Value::List(items) => {
1987 panproto_expr::Literal::List(items.iter().map(value_to_expr_literal).collect())
1988 }
1989 Value::Unknown(map) => {
1990 let mut fields: Vec<(std::sync::Arc<str>, panproto_expr::Literal)> = map
1991 .iter()
1992 .map(|(k, v)| (std::sync::Arc::from(k.as_str()), value_to_expr_literal(v)))
1993 .collect();
1994 fields.sort_by(|(a, _), (b, _)| a.cmp(b));
1995 panproto_expr::Literal::Record(fields)
1996 }
1997 _ => panproto_expr::Literal::Null,
1998 }
1999}
2000
2001#[must_use]
2017pub fn expr_literal_to_value(lit: &panproto_expr::Literal) -> Value {
2018 match lit {
2019 panproto_expr::Literal::Bool(b) => Value::Bool(*b),
2020 panproto_expr::Literal::Int(i) => Value::Int(*i),
2021 panproto_expr::Literal::Float(f) => {
2022 #[allow(clippy::cast_precision_loss)]
2025 let fits = f.fract() == 0.0 && *f >= i64::MIN as f64 && *f <= i64::MAX as f64;
2026 if fits {
2027 #[allow(clippy::cast_possible_truncation)]
2028 let i = *f as i64;
2029 Value::Int(i)
2030 } else {
2031 Value::Float(*f)
2032 }
2033 }
2034 panproto_expr::Literal::Str(s) => Value::Str(s.clone()),
2035 panproto_expr::Literal::Bytes(b) => Value::Bytes(b.clone()),
2036 panproto_expr::Literal::List(items) => {
2037 Value::List(items.iter().map(expr_literal_to_value).collect())
2038 }
2039 panproto_expr::Literal::Record(fields) => Value::Unknown(
2040 fields
2041 .iter()
2042 .map(|(k, v)| (k.to_string(), expr_literal_to_value(v)))
2043 .collect(),
2044 ),
2045 panproto_expr::Literal::Null | panproto_expr::Literal::Closure { .. } => Value::Null,
2046 }
2047}
2048
2049fn prepare_root_node(
2056 root_node: &Node,
2057 migration: &CompiledMigration,
2058 instance: &WInstance,
2059 src_schema: &Schema,
2060) -> Result<Node, RestrictError> {
2061 let mut node = root_node.clone();
2062 if let Some(remapped) = migration.vertex_remap.get(&root_node.anchor) {
2063 node.anchor.clone_from(remapped);
2064 }
2065 if let Some(pred) = migration.conditional_survival.get(&root_node.anchor) {
2066 let env = build_env_from_extra_fields(&root_node.extra_fields);
2067 let config = panproto_expr::EvalConfig::default();
2068 if matches!(
2069 panproto_expr::eval(pred, &env, &config),
2070 Ok(panproto_expr::Literal::Bool(false))
2071 ) {
2072 return Err(RestrictError::RootPruned);
2073 }
2074 }
2075 let transforms = migration.value_transforms(&root_node.anchor);
2076 if !transforms.is_empty() {
2077 let ctx = TransformContext::new(Some(src_schema), instance, root_node.id, &transforms);
2078 apply_field_transforms(&mut node, &transforms, &ctx)?;
2079 }
2080 Ok(node)
2081}
2082
2083pub fn wtype_extend(
2105 instance: &WInstance,
2106 tgt_schema: &Schema,
2107 migration: &CompiledMigration,
2108) -> Result<WInstance, RestrictError> {
2109 let (extended, _dropped) = wtype_extend_inner(instance, tgt_schema, migration, false)?;
2110 Ok(extended)
2111}
2112
2113pub fn wtype_extend_partial(
2129 instance: &WInstance,
2130 tgt_schema: &Schema,
2131 migration: &CompiledMigration,
2132) -> Result<(WInstance, Vec<u32>), RestrictError> {
2133 wtype_extend_inner(instance, tgt_schema, migration, true)
2134}
2135
2136fn wtype_extend_inner(
2142 instance: &WInstance,
2143 tgt_schema: &Schema,
2144 migration: &CompiledMigration,
2145 partial: bool,
2146) -> Result<(WInstance, Vec<u32>), RestrictError> {
2147 let mut dropped_nodes: Vec<u32> = Vec::new();
2149 let root_node = instance
2150 .nodes
2151 .get(&instance.root)
2152 .ok_or(RestrictError::RootPruned)?;
2153
2154 let root_anchor = &root_node.anchor;
2155 if !migration.surviving_verts.contains(root_anchor)
2156 && !migration.vertex_remap.contains_key(root_anchor)
2157 {
2158 return Err(RestrictError::RootPruned);
2159 }
2160
2161 let mut new_nodes: HashMap<u32, Node> = HashMap::with_capacity(instance.nodes.len());
2163 for (&id, node) in &instance.nodes {
2164 let mut new_node = node.clone();
2165 if let Some(remapped) = migration.vertex_remap.get(&node.anchor) {
2166 new_node.anchor.clone_from(remapped);
2167 } else if !migration.surviving_verts.contains(&node.anchor) {
2168 if partial {
2172 dropped_nodes.push(id);
2173 continue;
2174 }
2175 return Err(RestrictError::UnmappedAnchor {
2176 anchor: node.anchor.clone(),
2177 node_id: id,
2178 });
2179 }
2180 let transforms = migration.value_transforms(&node.anchor);
2184 if !transforms.is_empty() {
2185 let ctx = TransformContext::new(None, instance, id, &transforms);
2186 apply_field_transforms(&mut new_node, &transforms, &ctx)?;
2187 }
2188 new_nodes.insert(id, new_node);
2189 }
2190
2191 let mut new_arcs: Vec<(u32, u32, Edge)> = Vec::with_capacity(instance.arcs.len());
2193 for &(parent, child, ref edge) in &instance.arcs {
2194 if !new_nodes.contains_key(&parent) || !new_nodes.contains_key(&child) {
2196 continue;
2197 }
2198
2199 if let Some(new_edge) = migration.edge_remap.get(edge) {
2200 new_arcs.push((parent, child, new_edge.clone()));
2201 } else if migration.surviving_edges.contains(edge) {
2202 let parent_anchor = &new_nodes[&parent].anchor;
2205 let child_anchor = &new_nodes[&child].anchor;
2206 if edge.src == *parent_anchor && edge.tgt == *child_anchor {
2207 new_arcs.push((parent, child, edge.clone()));
2208 } else {
2209 let resolved =
2211 resolve_edge(tgt_schema, &migration.resolver, parent_anchor, child_anchor)?;
2212 new_arcs.push((parent, child, resolved));
2213 }
2214 } else {
2215 let parent_anchor = &new_nodes[&parent].anchor;
2218 let child_anchor = &new_nodes[&child].anchor;
2219 let resolved =
2220 resolve_edge(tgt_schema, &migration.resolver, parent_anchor, child_anchor)?;
2221 new_arcs.push((parent, child, resolved));
2222 }
2223 }
2224
2225 let surviving_ids: FxHashSet<u32> = new_nodes.keys().copied().collect();
2227 let empty_ancestors = FxHashMap::default();
2228 let new_fans = reconstruct_fans(
2229 instance,
2230 &surviving_ids,
2231 &empty_ancestors,
2232 migration,
2233 tgt_schema,
2234 )?;
2235
2236 let new_schema_root = migration
2237 .vertex_remap
2238 .get(&instance.schema_root)
2239 .cloned()
2240 .unwrap_or_else(|| instance.schema_root.clone());
2241
2242 Ok((
2243 WInstance::new(
2244 new_nodes,
2245 new_arcs,
2246 new_fans,
2247 instance.root,
2248 new_schema_root,
2249 ),
2250 dropped_nodes,
2251 ))
2252}
2253
2254#[cfg(test)]
2255mod tests {
2256 use super::*;
2257 use crate::value::{FieldPresence, Value};
2258
2259 fn three_node_instance() -> WInstance {
2261 let mut nodes = HashMap::new();
2262 nodes.insert(0, Node::new(0, panproto_gat::Name::from("post:body")));
2263 nodes.insert(
2264 1,
2265 Node::new(1, "post:body.text")
2266 .with_value(FieldPresence::Present(Value::Str("hello".into()))),
2267 );
2268 nodes.insert(
2269 2,
2270 Node::new(2, "post:body.createdAt")
2271 .with_value(FieldPresence::Present(Value::Str("2024-01-01".into()))),
2272 );
2273
2274 let arcs = vec![
2275 (
2276 0,
2277 1,
2278 Edge {
2279 src: "post:body".into(),
2280 tgt: "post:body.text".into(),
2281 kind: "prop".into(),
2282 name: Some("text".into()),
2283 },
2284 ),
2285 (
2286 0,
2287 2,
2288 Edge {
2289 src: "post:body".into(),
2290 tgt: "post:body.createdAt".into(),
2291 kind: "prop".into(),
2292 name: Some("createdAt".into()),
2293 },
2294 ),
2295 ];
2296
2297 WInstance::new(
2298 nodes,
2299 arcs,
2300 vec![],
2301 0,
2302 panproto_gat::Name::from("post:body"),
2303 )
2304 }
2305
2306 #[test]
2307 fn anchor_surviving_keeps_matching_nodes() {
2308 let inst = three_node_instance();
2309 let surviving_verts: HashSet<Name> = ["post:body", "post:body.text"]
2310 .iter()
2311 .map(|&s| Name::from(s))
2312 .collect();
2313
2314 let result = anchor_surviving(&inst, &surviving_verts);
2315 assert_eq!(result.len(), 2);
2316 assert!(result.contains(&0));
2317 assert!(result.contains(&1));
2318 assert!(!result.contains(&2));
2319 }
2320
2321 #[test]
2322 fn ancestor_contraction_direct_parent() {
2323 let inst = three_node_instance();
2324 let surviving: HashSet<u32> = [0, 1, 2].iter().copied().collect();
2325 let ancestors = ancestor_contraction(&inst, &surviving);
2326 assert_eq!(ancestors.get(&1), Some(&0));
2327 assert_eq!(ancestors.get(&2), Some(&0));
2328 }
2329
2330 #[test]
2331 fn resolve_edge_unique() {
2332 use smallvec::smallvec;
2333 let mut between = HashMap::new();
2334 let edge = Edge {
2335 src: "a".into(),
2336 tgt: "b".into(),
2337 kind: "prop".into(),
2338 name: Some("x".into()),
2339 };
2340 between.insert((Name::from("a"), Name::from("b")), smallvec![edge.clone()]);
2341
2342 let schema = Schema {
2343 protocol: "test".into(),
2344 vertices: HashMap::new(),
2345 edges: HashMap::new(),
2346 hyper_edges: HashMap::new(),
2347 constraints: HashMap::new(),
2348 required: HashMap::new(),
2349 nsids: HashMap::new(),
2350 entries: Vec::new(),
2351 variants: HashMap::new(),
2352 orderings: HashMap::new(),
2353 recursion_points: HashMap::new(),
2354 spans: HashMap::new(),
2355 usage_modes: HashMap::new(),
2356 nominal: HashMap::new(),
2357 coercions: HashMap::new(),
2358 mergers: HashMap::new(),
2359 defaults: HashMap::new(),
2360 policies: HashMap::new(),
2361 outgoing: HashMap::new(),
2362 incoming: HashMap::new(),
2363 between,
2364 };
2365
2366 let resolver = HashMap::new();
2367 let result = resolve_edge(&schema, &resolver, "a", "b");
2368 assert!(result.is_ok());
2369 assert_eq!(result.ok(), Some(edge));
2370 }
2371
2372 #[test]
2373 fn resolve_edge_uses_resolver() {
2374 let schema = Schema {
2375 protocol: "test".into(),
2376 vertices: HashMap::new(),
2377 edges: HashMap::new(),
2378 hyper_edges: HashMap::new(),
2379 constraints: HashMap::new(),
2380 required: HashMap::new(),
2381 nsids: HashMap::new(),
2382 entries: Vec::new(),
2383 variants: HashMap::new(),
2384 orderings: HashMap::new(),
2385 recursion_points: HashMap::new(),
2386 spans: HashMap::new(),
2387 usage_modes: HashMap::new(),
2388 nominal: HashMap::new(),
2389 coercions: HashMap::new(),
2390 mergers: HashMap::new(),
2391 defaults: HashMap::new(),
2392 policies: HashMap::new(),
2393 outgoing: HashMap::new(),
2394 incoming: HashMap::new(),
2395 between: HashMap::new(),
2396 };
2397
2398 let resolved_edge = Edge {
2399 src: "a".into(),
2400 tgt: "b".into(),
2401 kind: "prop".into(),
2402 name: Some("resolved".into()),
2403 };
2404 let mut resolver = HashMap::new();
2405 resolver.insert((Name::from("a"), Name::from("b")), resolved_edge.clone());
2406
2407 let result = resolve_edge(&schema, &resolver, "a", "b");
2408 assert!(result.is_ok());
2409 assert_eq!(result.ok(), Some(resolved_edge));
2410 }
2411
2412 #[allow(clippy::unwrap_used)]
2415 fn make_test_schema(vertices: &[&str], edges: &[Edge]) -> Schema {
2416 use smallvec::smallvec;
2417 let mut between = HashMap::new();
2418 for edge in edges {
2419 between
2420 .entry((Name::from(&*edge.src), Name::from(&*edge.tgt)))
2421 .or_insert_with(|| smallvec![])
2422 .push(edge.clone());
2423 }
2424 Schema {
2425 protocol: "test".into(),
2426 vertices: vertices
2427 .iter()
2428 .map(|&v| {
2429 (
2430 Name::from(v),
2431 panproto_schema::Vertex {
2432 id: Name::from(v),
2433 kind: Name::from("object"),
2434 nsid: None,
2435 },
2436 )
2437 })
2438 .collect(),
2439 edges: HashMap::new(),
2440 hyper_edges: HashMap::new(),
2441 constraints: HashMap::new(),
2442 required: HashMap::new(),
2443 nsids: HashMap::new(),
2444 entries: Vec::new(),
2445 variants: HashMap::new(),
2446 orderings: HashMap::new(),
2447 recursion_points: HashMap::new(),
2448 spans: HashMap::new(),
2449 usage_modes: HashMap::new(),
2450 nominal: HashMap::new(),
2451 coercions: HashMap::new(),
2452 mergers: HashMap::new(),
2453 defaults: HashMap::new(),
2454 policies: HashMap::new(),
2455 outgoing: HashMap::new(),
2456 incoming: HashMap::new(),
2457 between,
2458 }
2459 }
2460
2461 #[test]
2462 #[allow(clippy::unwrap_used)]
2463 fn extend_identity_migration() {
2464 let inst = three_node_instance();
2465 let edge_text = Edge {
2466 src: "post:body".into(),
2467 tgt: "post:body.text".into(),
2468 kind: "prop".into(),
2469 name: Some("text".into()),
2470 };
2471 let edge_time = Edge {
2472 src: "post:body".into(),
2473 tgt: "post:body.createdAt".into(),
2474 kind: "prop".into(),
2475 name: Some("createdAt".into()),
2476 };
2477 let surviving_edges = HashSet::from([edge_text.clone(), edge_time.clone()]);
2478 let schema = make_test_schema(
2479 &["post:body", "post:body.text", "post:body.createdAt"],
2480 &[edge_text, edge_time],
2481 );
2482 let migration = CompiledMigration {
2483 surviving_verts: HashSet::from([
2484 Name::from("post:body"),
2485 Name::from("post:body.text"),
2486 Name::from("post:body.createdAt"),
2487 ]),
2488 surviving_edges,
2489 vertex_remap: HashMap::new(),
2490 edge_remap: HashMap::new(),
2491 resolver: HashMap::new(),
2492 hyper_resolver: HashMap::new(),
2493 field_transforms: HashMap::new(),
2494 conditional_survival: HashMap::new(),
2495 op_term_assignments: HashMap::new(),
2496 expansion_path: HashMap::new(),
2497 };
2498 let result = wtype_extend(&inst, &schema, &migration).unwrap();
2499 assert_eq!(result.node_count(), 3);
2500 assert_eq!(result.arc_count(), 2);
2501 assert_eq!(result.schema_root, Name::from("post:body"));
2502 }
2503
2504 #[test]
2505 #[allow(clippy::unwrap_used)]
2506 fn extend_with_vertex_remap() {
2507 let inst = three_node_instance();
2508 let tgt_edge_text = Edge {
2509 src: "article:body".into(),
2510 tgt: "article:body.text".into(),
2511 kind: "prop".into(),
2512 name: Some("text".into()),
2513 };
2514 let tgt_edge_time = Edge {
2515 src: "article:body".into(),
2516 tgt: "article:body.createdAt".into(),
2517 kind: "prop".into(),
2518 name: Some("createdAt".into()),
2519 };
2520 let tgt_schema = make_test_schema(
2521 &[
2522 "article:body",
2523 "article:body.text",
2524 "article:body.createdAt",
2525 ],
2526 &[tgt_edge_text, tgt_edge_time],
2527 );
2528 let mut vertex_remap = HashMap::new();
2529 vertex_remap.insert(Name::from("post:body"), Name::from("article:body"));
2530 vertex_remap.insert(
2531 Name::from("post:body.text"),
2532 Name::from("article:body.text"),
2533 );
2534 vertex_remap.insert(
2535 Name::from("post:body.createdAt"),
2536 Name::from("article:body.createdAt"),
2537 );
2538 let migration = CompiledMigration {
2539 surviving_verts: HashSet::from([
2540 Name::from("article:body"),
2541 Name::from("article:body.text"),
2542 Name::from("article:body.createdAt"),
2543 ]),
2544 surviving_edges: HashSet::new(),
2545 vertex_remap,
2546 edge_remap: HashMap::new(),
2547 resolver: HashMap::new(),
2548 hyper_resolver: HashMap::new(),
2549 field_transforms: HashMap::new(),
2550 conditional_survival: HashMap::new(),
2551 op_term_assignments: HashMap::new(),
2552 expansion_path: HashMap::new(),
2553 };
2554 let result = wtype_extend(&inst, &tgt_schema, &migration).unwrap();
2555 assert_eq!(result.node_count(), 3);
2556 assert_eq!(result.arc_count(), 2);
2557 assert_eq!(result.schema_root, Name::from("article:body"));
2558 assert_eq!(result.nodes[&0].anchor, Name::from("article:body"));
2559 assert_eq!(result.nodes[&1].anchor, Name::from("article:body.text"));
2560 }
2561
2562 #[test]
2563 #[allow(clippy::unwrap_used)]
2564 fn extend_with_edge_remap() {
2565 let inst = three_node_instance();
2566 let src_edge_text = Edge {
2567 src: "post:body".into(),
2568 tgt: "post:body.text".into(),
2569 kind: "prop".into(),
2570 name: Some("text".into()),
2571 };
2572 let new_edge_text = Edge {
2573 src: "post:body".into(),
2574 tgt: "post:body.text".into(),
2575 kind: "prop".into(),
2576 name: Some("content".into()),
2577 };
2578 let edge_time = Edge {
2579 src: "post:body".into(),
2580 tgt: "post:body.createdAt".into(),
2581 kind: "prop".into(),
2582 name: Some("createdAt".into()),
2583 };
2584 let surviving_edges = HashSet::from([edge_time.clone()]);
2585 let tgt_schema = make_test_schema(
2586 &["post:body", "post:body.text", "post:body.createdAt"],
2587 &[new_edge_text.clone(), edge_time],
2588 );
2589 let mut edge_remap = HashMap::new();
2590 edge_remap.insert(src_edge_text, new_edge_text);
2591 let migration = CompiledMigration {
2592 surviving_verts: HashSet::from([
2593 Name::from("post:body"),
2594 Name::from("post:body.text"),
2595 Name::from("post:body.createdAt"),
2596 ]),
2597 surviving_edges,
2598 vertex_remap: HashMap::new(),
2599 edge_remap,
2600 resolver: HashMap::new(),
2601 hyper_resolver: HashMap::new(),
2602 field_transforms: HashMap::new(),
2603 conditional_survival: HashMap::new(),
2604 op_term_assignments: HashMap::new(),
2605 expansion_path: HashMap::new(),
2606 };
2607 let result = wtype_extend(&inst, &tgt_schema, &migration).unwrap();
2608 assert_eq!(result.arc_count(), 2);
2609 let text_arc = result.arcs.iter().find(|a| a.1 == 1).unwrap();
2611 assert_eq!(text_arc.2.name.as_deref(), Some("content"));
2612 }
2613
2614 #[test]
2615 #[allow(clippy::unwrap_used)]
2616 fn extend_preserves_structure() {
2617 let inst = three_node_instance();
2618 let edge_text = Edge {
2619 src: "post:body".into(),
2620 tgt: "post:body.text".into(),
2621 kind: "prop".into(),
2622 name: Some("text".into()),
2623 };
2624 let edge_time = Edge {
2625 src: "post:body".into(),
2626 tgt: "post:body.createdAt".into(),
2627 kind: "prop".into(),
2628 name: Some("createdAt".into()),
2629 };
2630 let surviving_edges = HashSet::from([edge_text.clone(), edge_time.clone()]);
2631 let schema = make_test_schema(
2632 &["post:body", "post:body.text", "post:body.createdAt"],
2633 &[edge_text, edge_time],
2634 );
2635 let migration = CompiledMigration {
2636 surviving_verts: HashSet::from([
2637 Name::from("post:body"),
2638 Name::from("post:body.text"),
2639 Name::from("post:body.createdAt"),
2640 ]),
2641 surviving_edges,
2642 vertex_remap: HashMap::new(),
2643 edge_remap: HashMap::new(),
2644 resolver: HashMap::new(),
2645 hyper_resolver: HashMap::new(),
2646 field_transforms: HashMap::new(),
2647 conditional_survival: HashMap::new(),
2648 op_term_assignments: HashMap::new(),
2649 expansion_path: HashMap::new(),
2650 };
2651 let result = wtype_extend(&inst, &schema, &migration).unwrap();
2652 assert_eq!(result.parent(1), Some(0));
2654 assert_eq!(result.parent(2), Some(0));
2655 assert!(result.children(0).contains(&1));
2656 assert!(result.children(0).contains(&2));
2657 assert!(result.nodes[&1].has_value());
2659 assert!(result.nodes[&2].has_value());
2660 }
2661
2662 #[test]
2663 #[allow(clippy::unwrap_used)]
2664 fn extend_errors_on_unmapped_anchor() {
2665 let inst = three_node_instance();
2668 let edge_text = Edge {
2669 src: "post:body".into(),
2670 tgt: "post:body.text".into(),
2671 kind: "prop".into(),
2672 name: Some("text".into()),
2673 };
2674 let schema = make_test_schema(
2676 &["post:body", "post:body.text"],
2677 std::slice::from_ref(&edge_text),
2678 );
2679 let migration = CompiledMigration {
2682 surviving_verts: HashSet::from([Name::from("post:body"), Name::from("post:body.text")]),
2683 surviving_edges: HashSet::from([edge_text]),
2684 vertex_remap: HashMap::new(),
2685 edge_remap: HashMap::new(),
2686 resolver: HashMap::new(),
2687 hyper_resolver: HashMap::new(),
2688 field_transforms: HashMap::new(),
2689 conditional_survival: HashMap::new(),
2690 op_term_assignments: HashMap::new(),
2691 expansion_path: HashMap::new(),
2692 };
2693
2694 let err = wtype_extend(&inst, &schema, &migration).unwrap_err();
2696 assert!(
2697 matches!(err, RestrictError::UnmappedAnchor { node_id: 2, .. }),
2698 "expected UnmappedAnchor for node 2, got {err:?}"
2699 );
2700
2701 let (extended, dropped) = wtype_extend_partial(&inst, &schema, &migration).unwrap();
2703 assert_eq!(dropped, vec![2]);
2704 assert_eq!(extended.node_count(), 2);
2705 assert!(!extended.nodes.contains_key(&2));
2706 }
2707
2708 #[test]
2715 #[allow(clippy::expect_used, clippy::too_many_lines)]
2716 fn restrict_renamed_vertex_preserves_value() {
2717 use smallvec::smallvec;
2718
2719 let mut nodes = HashMap::new();
2721 nodes.insert(0, Node::new(0, Name::from("post:body")));
2722 nodes.insert(
2723 1,
2724 Node::new(1, "post:text")
2725 .with_value(FieldPresence::Present(Value::Str("hello".into()))),
2726 );
2727 nodes.insert(
2728 2,
2729 Node::new(2, "post:title")
2730 .with_value(FieldPresence::Present(Value::Str("world".into()))),
2731 );
2732 let arcs = vec![
2733 (
2734 0,
2735 1,
2736 Edge {
2737 src: "post:body".into(),
2738 tgt: "post:text".into(),
2739 kind: "prop".into(),
2740 name: Some("text".into()),
2741 },
2742 ),
2743 (
2744 0,
2745 2,
2746 Edge {
2747 src: "post:body".into(),
2748 tgt: "post:title".into(),
2749 kind: "prop".into(),
2750 name: Some("title".into()),
2751 },
2752 ),
2753 ];
2754 let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("post:body"));
2755
2756 let tgt_content_edge = Edge {
2758 src: "post:body".into(),
2759 tgt: "post:content".into(),
2760 kind: "prop".into(),
2761 name: Some("content".into()),
2762 };
2763 let tgt_title_edge = Edge {
2764 src: "post:body".into(),
2765 tgt: "post:title".into(),
2766 kind: "prop".into(),
2767 name: Some("title".into()),
2768 };
2769 let mut tgt_between = HashMap::new();
2770 tgt_between.insert(
2771 (Name::from("post:body"), Name::from("post:content")),
2772 smallvec![tgt_content_edge],
2773 );
2774 tgt_between.insert(
2775 (Name::from("post:body"), Name::from("post:title")),
2776 smallvec![tgt_title_edge],
2777 );
2778 let tgt_schema = Schema {
2779 protocol: "test".into(),
2780 vertices: HashMap::new(),
2781 edges: HashMap::new(),
2782 hyper_edges: HashMap::new(),
2783 constraints: HashMap::new(),
2784 required: HashMap::new(),
2785 nsids: HashMap::new(),
2786 entries: Vec::new(),
2787 variants: HashMap::new(),
2788 orderings: HashMap::new(),
2789 recursion_points: HashMap::new(),
2790 spans: HashMap::new(),
2791 usage_modes: HashMap::new(),
2792 nominal: HashMap::new(),
2793 coercions: HashMap::new(),
2794 mergers: HashMap::new(),
2795 defaults: HashMap::new(),
2796 policies: HashMap::new(),
2797 outgoing: HashMap::new(),
2798 incoming: HashMap::new(),
2799 between: tgt_between,
2800 };
2801
2802 let mut surviving_verts = HashSet::new();
2804 surviving_verts.insert(Name::from("post:body"));
2805 surviving_verts.insert(Name::from("post:content")); surviving_verts.insert(Name::from("post:title"));
2807
2808 let mut vertex_remap = HashMap::new();
2809 vertex_remap.insert(Name::from("post:text"), Name::from("post:content"));
2810
2811 let migration = CompiledMigration {
2812 surviving_verts,
2813 surviving_edges: HashSet::new(),
2814 vertex_remap,
2815 edge_remap: HashMap::new(),
2816 resolver: HashMap::new(),
2817 hyper_resolver: HashMap::new(),
2818 field_transforms: HashMap::new(),
2819 conditional_survival: HashMap::new(),
2820 op_term_assignments: HashMap::new(),
2821 expansion_path: HashMap::new(),
2822 };
2823
2824 let src_schema = Schema {
2825 protocol: "test".into(),
2826 vertices: HashMap::new(),
2827 edges: HashMap::new(),
2828 hyper_edges: HashMap::new(),
2829 constraints: HashMap::new(),
2830 required: HashMap::new(),
2831 nsids: HashMap::new(),
2832 entries: Vec::new(),
2833 variants: HashMap::new(),
2834 orderings: HashMap::new(),
2835 recursion_points: HashMap::new(),
2836 spans: HashMap::new(),
2837 usage_modes: HashMap::new(),
2838 nominal: HashMap::new(),
2839 coercions: HashMap::new(),
2840 mergers: HashMap::new(),
2841 defaults: HashMap::new(),
2842 policies: HashMap::new(),
2843 outgoing: HashMap::new(),
2844 incoming: HashMap::new(),
2845 between: HashMap::new(),
2846 };
2847
2848 let result = wtype_restrict(&inst, &src_schema, &tgt_schema, &migration)
2849 .expect("restrict should succeed");
2850
2851 assert_eq!(result.nodes.len(), 3, "all three nodes should survive");
2853
2854 let renamed_node = result.nodes.get(&1).expect("node 1 should survive");
2856 assert_eq!(renamed_node.anchor.as_ref(), "post:content");
2857 assert!(renamed_node.has_value(), "renamed node must keep its value");
2858
2859 assert!(
2861 matches!(
2862 &renamed_node.value,
2863 Some(FieldPresence::Present(Value::Str(s))) if s.as_str() == "hello"
2864 ),
2865 "expected Some(Present(Str(\"hello\"))), got {:?}",
2866 renamed_node.value,
2867 );
2868 }
2869
2870 #[test]
2873 #[allow(clippy::expect_used)]
2874 fn path_transform_renames_nested_field() {
2875 let mut node = Node::new(0, "v");
2876 let mut inner_map = HashMap::new();
2877 inner_map.insert("old_attr".to_string(), Value::Str("val".into()));
2878 node.extra_fields
2879 .insert("attrs".to_string(), Value::Unknown(inner_map));
2880
2881 let transform = FieldTransform::PathTransform {
2882 path: vec!["attrs".to_string()],
2883 inner: Box::new(FieldTransform::RenameField {
2884 old_key: "old_attr".to_string(),
2885 new_key: "new_attr".to_string(),
2886 }),
2887 };
2888 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2889 .expect("transform should evaluate");
2890
2891 match node.extra_fields.get("attrs") {
2892 Some(Value::Unknown(map)) => {
2893 assert!(!map.contains_key("old_attr"));
2894 assert_eq!(map.get("new_attr"), Some(&Value::Str("val".into())));
2895 }
2896 other => panic!("expected Unknown map, got {other:?}"),
2897 }
2898 }
2899
2900 #[test]
2901 #[allow(clippy::expect_used)]
2902 fn path_transform_empty_path_is_identity() {
2903 let mut node = Node::new(0, "v");
2904 node.extra_fields
2905 .insert("color".to_string(), Value::Str("red".into()));
2906
2907 let transform = FieldTransform::PathTransform {
2908 path: vec![],
2909 inner: Box::new(FieldTransform::RenameField {
2910 old_key: "color".to_string(),
2911 new_key: "colour".to_string(),
2912 }),
2913 };
2914 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2915 .expect("transform should evaluate");
2916
2917 assert!(!node.extra_fields.contains_key("color"));
2918 assert_eq!(
2919 node.extra_fields.get("colour"),
2920 Some(&Value::Str("red".into()))
2921 );
2922 }
2923
2924 #[test]
2927 #[allow(clippy::expect_used)]
2928 fn map_references_renames_string_field() {
2929 let mut node = Node::new(0, "v");
2930 node.extra_fields
2931 .insert("parent".to_string(), Value::Str("old_name".into()));
2932
2933 let mut rename_map = HashMap::new();
2934 rename_map.insert("old_name".to_string(), Some("new_name".to_string()));
2935
2936 let transform = FieldTransform::MapReferences {
2937 field: "parent".to_string(),
2938 rename_map,
2939 };
2940 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2941 .expect("transform should evaluate");
2942
2943 assert_eq!(
2944 node.extra_fields.get("parent"),
2945 Some(&Value::Str("new_name".into()))
2946 );
2947 }
2948
2949 #[test]
2950 #[allow(clippy::expect_used)]
2951 fn map_references_filters_list() {
2952 let mut node = Node::new(0, "v");
2953 node.extra_fields.insert(
2954 "parents".to_string(),
2955 Value::List(vec![
2956 Value::Str("alpha".into()),
2957 Value::Str("beta".into()),
2958 Value::Str("gamma".into()),
2959 ]),
2960 );
2961
2962 let mut rename_map = HashMap::new();
2963 rename_map.insert("alpha".to_string(), Some("alpha_v2".to_string()));
2964 rename_map.insert("beta".to_string(), None); let transform = FieldTransform::MapReferences {
2967 field: "parents".to_string(),
2968 rename_map,
2969 };
2970 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
2971 .expect("transform should evaluate");
2972
2973 match node.extra_fields.get("parents") {
2974 Some(Value::List(items)) => {
2975 assert_eq!(items.len(), 2);
2976 assert_eq!(items[0], Value::Str("alpha_v2".into()));
2977 assert_eq!(items[1], Value::Str("gamma".into()));
2978 }
2979 other => panic!("expected List, got {other:?}"),
2980 }
2981 }
2982
2983 #[test]
2984 #[allow(clippy::expect_used)]
2985 fn map_references_drops_removed_entries() {
2986 let mut node = Node::new(0, "v");
2987 node.extra_fields.insert(
2988 "refs".to_string(),
2989 Value::List(vec![
2990 Value::Str("gone".into()),
2991 Value::Str("also_gone".into()),
2992 ]),
2993 );
2994
2995 let mut rename_map = HashMap::new();
2996 rename_map.insert("gone".to_string(), None);
2997 rename_map.insert("also_gone".to_string(), None);
2998
2999 let transform = FieldTransform::MapReferences {
3000 field: "refs".to_string(),
3001 rename_map,
3002 };
3003 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3004 .expect("transform should evaluate");
3005
3006 match node.extra_fields.get("refs") {
3007 Some(Value::List(items)) => {
3008 assert!(items.is_empty(), "expected empty list, got {items:?}");
3009 }
3010 other => panic!("expected List, got {other:?}"),
3011 }
3012 }
3013
3014 #[test]
3015 fn value_to_expr_literal_preserves_string_list() {
3016 let val = Value::List(vec![
3019 Value::Str("a".into()),
3020 Value::Str("b".into()),
3021 Value::Str("c".into()),
3022 ]);
3023 match value_to_expr_literal(&val) {
3024 panproto_expr::Literal::List(items) => assert_eq!(
3025 items,
3026 vec![
3027 panproto_expr::Literal::Str("a".into()),
3028 panproto_expr::Literal::Str("b".into()),
3029 panproto_expr::Literal::Str("c".into()),
3030 ]
3031 ),
3032 other => panic!("expected Literal::List, got {other:?}"),
3033 }
3034 }
3035
3036 #[test]
3037 fn value_to_expr_literal_keeps_non_string_list_elements() {
3038 let val = Value::List(vec![
3042 Value::Str("keep".into()),
3043 Value::Int(42),
3044 Value::Bool(true),
3045 Value::Null,
3046 ]);
3047 match value_to_expr_literal(&val) {
3048 panproto_expr::Literal::List(items) => assert_eq!(
3049 items,
3050 vec![
3051 panproto_expr::Literal::Str("keep".into()),
3052 panproto_expr::Literal::Int(42),
3053 panproto_expr::Literal::Bool(true),
3054 panproto_expr::Literal::Null,
3055 ],
3056 "non-string list elements must survive the conversion"
3057 ),
3058 other => panic!("expected Literal::List, got {other:?}"),
3059 }
3060 }
3061
3062 #[test]
3063 fn value_to_expr_literal_empty_list_is_empty_list() {
3064 match value_to_expr_literal(&Value::List(Vec::new())) {
3065 panproto_expr::Literal::List(items) => assert!(items.is_empty()),
3066 other => panic!("expected Literal::List, got {other:?}"),
3067 }
3068 }
3069
3070 #[test]
3071 fn value_to_expr_literal_nests_lists_and_records() {
3072 let val = Value::List(vec![Value::Unknown(HashMap::from([
3075 ("a".to_string(), Value::Int(1)),
3076 ("b".to_string(), Value::Int(10)),
3077 ]))]);
3078 match value_to_expr_literal(&val) {
3079 panproto_expr::Literal::List(items) => match &items[..] {
3080 [panproto_expr::Literal::Record(fields)] => {
3081 assert_eq!(fields.len(), 2);
3082 assert_eq!(&*fields[0].0, "a");
3083 assert_eq!(fields[0].1, panproto_expr::Literal::Int(1));
3084 assert_eq!(&*fields[1].0, "b");
3085 assert_eq!(fields[1].1, panproto_expr::Literal::Int(10));
3086 }
3087 other => panic!("expected one Record element, got {other:?}"),
3088 },
3089 other => panic!("expected Literal::List, got {other:?}"),
3090 }
3091 }
3092
3093 #[test]
3094 fn value_to_expr_literal_record_fields_are_sorted() {
3095 let val = Value::Unknown(HashMap::from([
3099 ("zulu".to_string(), Value::Int(3)),
3100 ("alpha".to_string(), Value::Int(1)),
3101 ("mike".to_string(), Value::Int(2)),
3102 ]));
3103 for _ in 0..16 {
3104 match value_to_expr_literal(&val) {
3105 panproto_expr::Literal::Record(fields) => {
3106 let keys: Vec<&str> = fields.iter().map(|(k, _)| &**k).collect();
3107 assert_eq!(keys, vec!["alpha", "mike", "zulu"]);
3108 }
3109 other => panic!("expected Literal::Record, got {other:?}"),
3110 }
3111 }
3112 }
3113
3114 #[test]
3115 fn value_to_expr_literal_non_collection_variants_pass_through() {
3116 assert!(matches!(
3117 value_to_expr_literal(&Value::Bool(true)),
3118 panproto_expr::Literal::Bool(true)
3119 ));
3120 assert!(matches!(
3121 value_to_expr_literal(&Value::Int(7)),
3122 panproto_expr::Literal::Int(7)
3123 ));
3124 assert!(matches!(
3125 value_to_expr_literal(&Value::Null),
3126 panproto_expr::Literal::Null
3127 ));
3128 assert_eq!(
3129 value_to_expr_literal(&Value::Bytes(vec![1, 2, 3])),
3130 panproto_expr::Literal::Bytes(vec![1, 2, 3])
3131 );
3132 assert!(matches!(
3134 value_to_expr_literal(&Value::Unknown(HashMap::new())),
3135 panproto_expr::Literal::Record(ref f) if f.is_empty()
3136 ));
3137 assert!(matches!(
3139 value_to_expr_literal(&Value::Token("t".into())),
3140 panproto_expr::Literal::Null
3141 ));
3142 }
3143
3144 #[test]
3145 fn expr_literal_to_value_preserves_lists_and_records() {
3146 let lit = panproto_expr::Literal::List(vec![panproto_expr::Literal::Record(vec![
3150 (std::sync::Arc::from("x"), panproto_expr::Literal::Int(1)),
3151 (
3152 std::sync::Arc::from("y"),
3153 panproto_expr::Literal::Str("s".into()),
3154 ),
3155 ])]);
3156 match expr_literal_to_value(&lit) {
3157 Value::List(items) => match &items[..] {
3158 [Value::Unknown(map)] => {
3159 assert_eq!(map.get("x"), Some(&Value::Int(1)));
3160 assert_eq!(map.get("y"), Some(&Value::Str("s".into())));
3161 }
3162 other => panic!("expected one Unknown element, got {other:?}"),
3163 },
3164 other => panic!("expected Value::List, got {other:?}"),
3165 }
3166 }
3167
3168 #[test]
3169 fn value_literal_round_trip_is_identity_on_containers() {
3170 let val = Value::Unknown(HashMap::from([
3174 (
3175 "nums".to_string(),
3176 Value::List(vec![Value::Int(1), Value::Int(2)]),
3177 ),
3178 (
3179 "nested".to_string(),
3180 Value::Unknown(HashMap::from([("a".to_string(), Value::Str("z".into()))])),
3181 ),
3182 ("flag".to_string(), Value::Bool(false)),
3183 ]));
3184 assert_eq!(expr_literal_to_value(&value_to_expr_literal(&val)), val);
3185 }
3186
3187 fn node_with_container_fields() -> Node {
3199 let mut node = Node::new(0, "rec");
3200 node.extra_fields.insert(
3201 "nums".to_string(),
3202 Value::List(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
3203 );
3204 node.extra_fields.insert(
3205 "objs".to_string(),
3206 Value::List(vec![
3207 Value::Unknown(HashMap::from([
3208 ("a".to_string(), Value::Int(1)),
3209 ("b".to_string(), Value::Int(10)),
3210 ])),
3211 Value::Unknown(HashMap::from([
3212 ("a".to_string(), Value::Int(2)),
3213 ("b".to_string(), Value::Int(20)),
3214 ])),
3215 ]),
3216 );
3217 node.extra_fields.insert(
3218 "nested".to_string(),
3219 Value::Unknown(HashMap::from([
3220 ("a".to_string(), Value::Int(7)),
3221 ("b".to_string(), Value::Int(70)),
3222 ])),
3223 );
3224 node
3225 }
3226
3227 fn increment_lambda() -> panproto_expr::Expr {
3229 panproto_expr::Expr::Lam(
3230 std::sync::Arc::from("x"),
3231 Box::new(panproto_expr::Expr::Builtin(
3232 panproto_expr::BuiltinOp::Add,
3233 vec![
3234 panproto_expr::Expr::Var(std::sync::Arc::from("x")),
3235 panproto_expr::Expr::Lit(panproto_expr::Literal::Int(1)),
3236 ],
3237 )),
3238 )
3239 }
3240
3241 #[test]
3242 #[allow(clippy::expect_used)]
3243 fn apply_expr_maps_over_an_integer_list() {
3244 let mut node = node_with_container_fields();
3246 let transform = FieldTransform::ApplyExpr {
3247 key: "nums".to_string(),
3248 expr: panproto_expr::Expr::Builtin(
3249 panproto_expr::BuiltinOp::Map,
3250 vec![
3251 panproto_expr::Expr::Var(std::sync::Arc::from("nums")),
3252 increment_lambda(),
3253 ],
3254 ),
3255 inverse: None,
3256 coercion_class: panproto_gat::CoercionClass::Projection,
3257 };
3258 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3259 .expect("map over a list field should evaluate");
3260
3261 assert_eq!(
3262 node.extra_fields.get("nums"),
3263 Some(&Value::List(vec![
3264 Value::Int(2),
3265 Value::Int(3),
3266 Value::Int(4)
3267 ])),
3268 "map over an integer list must produce the incremented list, not leave it untouched"
3269 );
3270 }
3271
3272 #[test]
3273 #[allow(clippy::expect_used)]
3274 fn apply_expr_maps_a_projection_over_a_record_list() {
3275 let mut node = node_with_container_fields();
3277 let project_a = panproto_expr::Expr::Lam(
3278 std::sync::Arc::from("o"),
3279 Box::new(panproto_expr::Expr::Field(
3280 Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
3281 std::sync::Arc::from("a"),
3282 )),
3283 );
3284 let transform = FieldTransform::ApplyExpr {
3285 key: "objs".to_string(),
3286 expr: panproto_expr::Expr::Builtin(
3287 panproto_expr::BuiltinOp::Map,
3288 vec![
3289 panproto_expr::Expr::Var(std::sync::Arc::from("objs")),
3290 project_a,
3291 ],
3292 ),
3293 inverse: None,
3294 coercion_class: panproto_gat::CoercionClass::Projection,
3295 };
3296 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3297 .expect("map over a record list should evaluate");
3298
3299 assert_eq!(
3300 node.extra_fields.get("objs"),
3301 Some(&Value::List(vec![Value::Int(1), Value::Int(2)])),
3302 "field projection over a list of records must reach each record's fields"
3303 );
3304 }
3305
3306 #[test]
3307 #[allow(clippy::expect_used)]
3308 fn compute_field_reads_through_a_nested_record() {
3309 let mut node = node_with_container_fields();
3311 let transform = FieldTransform::ComputeField {
3312 target_key: "out".to_string(),
3313 expr: panproto_expr::Expr::Field(
3314 Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("nested"))),
3315 std::sync::Arc::from("a"),
3316 ),
3317 inverse: None,
3318 coercion_class: panproto_gat::CoercionClass::Projection,
3319 };
3320 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3321 .expect("field access into a nested record should evaluate");
3322
3323 assert_eq!(
3324 node.extra_fields.get("out"),
3325 Some(&Value::Int(7)),
3326 "field access into a nested object must resolve, not emit nothing"
3327 );
3328 }
3329
3330 #[test]
3331 #[allow(clippy::expect_used)]
3332 fn compute_field_folds_over_a_list() {
3333 let mut node = node_with_container_fields();
3335 let add = panproto_expr::Expr::Lam(
3336 std::sync::Arc::from("x"),
3337 Box::new(panproto_expr::Expr::Lam(
3338 std::sync::Arc::from("y"),
3339 Box::new(panproto_expr::Expr::Builtin(
3340 panproto_expr::BuiltinOp::Add,
3341 vec![
3342 panproto_expr::Expr::Var(std::sync::Arc::from("x")),
3343 panproto_expr::Expr::Var(std::sync::Arc::from("y")),
3344 ],
3345 )),
3346 )),
3347 );
3348 let transform = FieldTransform::ComputeField {
3349 target_key: "out".to_string(),
3350 expr: panproto_expr::Expr::Builtin(
3351 panproto_expr::BuiltinOp::Fold,
3352 vec![
3353 panproto_expr::Expr::Var(std::sync::Arc::from("nums")),
3354 panproto_expr::Expr::Lit(panproto_expr::Literal::Int(0)),
3355 add,
3356 ],
3357 ),
3358 inverse: None,
3359 coercion_class: panproto_gat::CoercionClass::Projection,
3360 };
3361 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3362 .expect("fold over a list field should evaluate");
3363
3364 assert_eq!(
3365 node.extra_fields.get("out"),
3366 Some(&Value::Int(6)),
3367 "fold over an integer list must sum it"
3368 );
3369 }
3370
3371 #[test]
3372 #[allow(clippy::expect_used)]
3373 fn compute_field_builds_a_nested_record_list() {
3374 let mut node = node_with_container_fields();
3379 let regroup = panproto_expr::Expr::Lam(
3380 std::sync::Arc::from("o"),
3381 Box::new(panproto_expr::Expr::Record(vec![
3382 (
3383 std::sync::Arc::from("outer"),
3384 panproto_expr::Expr::Field(
3385 Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
3386 std::sync::Arc::from("a"),
3387 ),
3388 ),
3389 (
3390 std::sync::Arc::from("inner"),
3391 panproto_expr::Expr::Record(vec![(
3392 std::sync::Arc::from("deep"),
3393 panproto_expr::Expr::Field(
3394 Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
3395 std::sync::Arc::from("b"),
3396 ),
3397 )]),
3398 ),
3399 ])),
3400 );
3401 let transform = FieldTransform::ComputeField {
3402 target_key: "regrouped".to_string(),
3403 expr: panproto_expr::Expr::Builtin(
3404 panproto_expr::BuiltinOp::Map,
3405 vec![
3406 panproto_expr::Expr::Var(std::sync::Arc::from("objs")),
3407 regroup,
3408 ],
3409 ),
3410 inverse: None,
3411 coercion_class: panproto_gat::CoercionClass::Projection,
3412 };
3413 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3414 .expect("building a nested record list should evaluate");
3415
3416 let expected = Value::List(vec![
3417 Value::Unknown(HashMap::from([
3418 ("outer".to_string(), Value::Int(1)),
3419 (
3420 "inner".to_string(),
3421 Value::Unknown(HashMap::from([("deep".to_string(), Value::Int(10))])),
3422 ),
3423 ])),
3424 Value::Unknown(HashMap::from([
3425 ("outer".to_string(), Value::Int(2)),
3426 (
3427 "inner".to_string(),
3428 Value::Unknown(HashMap::from([("deep".to_string(), Value::Int(20))])),
3429 ),
3430 ])),
3431 ]);
3432 assert_eq!(node.extra_fields.get("regrouped"), Some(&expected));
3433 }
3434
3435 #[test]
3436 #[allow(clippy::expect_used)]
3437 fn contains_tests_membership_on_a_list_field() {
3438 let mut node = Node::new(0, "rec");
3441 node.extra_fields.insert(
3442 "tags".to_string(),
3443 Value::List(vec![Value::Str("alpha".into()), Value::Str("beta".into())]),
3444 );
3445
3446 let case = FieldTransform::Case {
3447 branches: vec![CaseBranch {
3448 predicate: panproto_expr::Expr::builtin(
3449 panproto_expr::BuiltinOp::Contains,
3450 vec![
3451 panproto_expr::Expr::Var(std::sync::Arc::from("tags")),
3452 panproto_expr::Expr::Lit(panproto_expr::Literal::Str("beta".into())),
3453 ],
3454 ),
3455 transforms: vec![FieldTransform::AddField {
3456 key: "matched".into(),
3457 value: Value::Bool(true),
3458 }],
3459 }],
3460 };
3461 apply_field_transforms(&mut node, &[case], &TransformContext::detached())
3462 .expect("membership predicate should evaluate");
3463
3464 assert_eq!(
3465 node.extra_fields.get("matched"),
3466 Some(&Value::Bool(true)),
3467 "Contains must test element membership on a list-valued field"
3468 );
3469 }
3470
3471 #[test]
3472 #[allow(clippy::expect_used)]
3473 fn contains_on_a_list_does_not_match_a_substring_of_an_element() {
3474 let mut node = Node::new(0, "rec");
3478 node.extra_fields.insert(
3479 "tags".to_string(),
3480 Value::List(vec![Value::Str("alpha".into())]),
3481 );
3482
3483 let case = FieldTransform::Case {
3484 branches: vec![CaseBranch {
3485 predicate: panproto_expr::Expr::builtin(
3486 panproto_expr::BuiltinOp::Contains,
3487 vec![
3488 panproto_expr::Expr::Var(std::sync::Arc::from("tags")),
3489 panproto_expr::Expr::Lit(panproto_expr::Literal::Str("lph".into())),
3490 ],
3491 ),
3492 transforms: vec![FieldTransform::AddField {
3493 key: "matched".into(),
3494 value: Value::Bool(true),
3495 }],
3496 }],
3497 };
3498 apply_field_transforms(&mut node, &[case], &TransformContext::detached())
3499 .expect("membership predicate should evaluate");
3500
3501 assert!(
3502 !node.extra_fields.contains_key("matched"),
3503 "list membership must be exact-element, not substring"
3504 );
3505 }
3506
3507 #[test]
3508 #[allow(clippy::expect_used)]
3509 fn failed_transform_reports_instead_of_silently_skipping() {
3510 let mut node = Node::new(0, "rec");
3514 node.extra_fields.insert("n".to_string(), Value::Int(1));
3515
3516 let transform = FieldTransform::ComputeField {
3517 target_key: "out".to_string(),
3518 expr: panproto_expr::Expr::Var(std::sync::Arc::from("missing")),
3520 inverse: None,
3521 coercion_class: panproto_gat::CoercionClass::Projection,
3522 };
3523 let err = apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3524 .expect_err("an unevaluable transform must report");
3525
3526 match err {
3527 RestrictError::FieldTransformFailed { key, .. } => assert_eq!(key, "out"),
3528 other => panic!("expected FieldTransformFailed, got {other:?}"),
3529 }
3530 assert!(
3531 !node.extra_fields.contains_key("out"),
3532 "a failed transform must not write a partial result"
3533 );
3534 }
3535
3536 #[test]
3537 #[allow(clippy::expect_used)]
3538 fn failed_nested_transform_preserves_the_nested_map() {
3539 let mut node = Node::new(0, "rec");
3543 node.extra_fields.insert(
3544 "outer".to_string(),
3545 Value::Unknown(HashMap::from([("kept".to_string(), Value::Int(5))])),
3546 );
3547
3548 let transform = FieldTransform::PathTransform {
3549 path: vec!["outer".to_string()],
3550 inner: Box::new(FieldTransform::ComputeField {
3551 target_key: "out".to_string(),
3552 expr: panproto_expr::Expr::Var(std::sync::Arc::from("missing")),
3553 inverse: None,
3554 coercion_class: panproto_gat::CoercionClass::Projection,
3555 }),
3556 };
3557 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3558 .expect_err("an unevaluable nested transform must report");
3559
3560 assert_eq!(
3561 node.extra_fields.get("outer"),
3562 Some(&Value::Unknown(HashMap::from([(
3563 "kept".to_string(),
3564 Value::Int(5)
3565 )]))),
3566 "the nested map must survive a failed transform"
3567 );
3568 }
3569
3570 #[test]
3571 #[allow(clippy::expect_used)]
3572 fn map_references_preserves_non_string_elements() {
3573 let mut node = Node::new(0, "v");
3578 node.extra_fields.insert(
3579 "mixed".to_string(),
3580 Value::List(vec![
3581 Value::Str("renameme".into()),
3582 Value::Int(42),
3583 Value::Bool(true),
3584 Value::Str("dropme".into()),
3585 ]),
3586 );
3587
3588 let mut rename_map = HashMap::new();
3589 rename_map.insert("renameme".to_string(), Some("renamed".to_string()));
3590 rename_map.insert("dropme".to_string(), None);
3591
3592 let transform = FieldTransform::MapReferences {
3593 field: "mixed".to_string(),
3594 rename_map,
3595 };
3596 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3597 .expect("transform should evaluate");
3598
3599 match node.extra_fields.get("mixed") {
3600 Some(Value::List(items)) => {
3601 assert_eq!(items.len(), 3);
3602 assert_eq!(items[0], Value::Str("renamed".into()));
3603 assert_eq!(items[1], Value::Int(42));
3604 assert_eq!(items[2], Value::Bool(true));
3605 }
3606 other => panic!("expected List, got {other:?}"),
3607 }
3608 }
3609
3610 #[test]
3613 #[allow(clippy::expect_used)]
3614 fn conditional_survival_drops_non_matching_node() {
3615 use smallvec::smallvec;
3616
3617 let mut nodes = HashMap::new();
3619 nodes.insert(0, Node::new(0, Name::from("root")));
3620 nodes.insert(
3621 1,
3622 Node::new(1, "item").with_extra_field("level", Value::Int(2)),
3623 );
3624 nodes.insert(
3625 2,
3626 Node::new(2, "item").with_extra_field("level", Value::Int(1)),
3627 );
3628
3629 let edge = Edge {
3630 src: "root".into(),
3631 tgt: "item".into(),
3632 kind: "prop".into(),
3633 name: Some("child".into()),
3634 };
3635 let arcs = vec![(0, 1, edge.clone()), (0, 2, edge.clone())];
3636 let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("root"));
3637
3638 let mut between = HashMap::new();
3639 between.insert(
3640 (Name::from("root"), Name::from("item")),
3641 smallvec![edge.clone()],
3642 );
3643 let tgt_schema = Schema {
3644 protocol: "test".into(),
3645 vertices: HashMap::new(),
3646 edges: HashMap::new(),
3647 hyper_edges: HashMap::new(),
3648 constraints: HashMap::new(),
3649 required: HashMap::new(),
3650 nsids: HashMap::new(),
3651 entries: Vec::new(),
3652 variants: HashMap::new(),
3653 orderings: HashMap::new(),
3654 recursion_points: HashMap::new(),
3655 spans: HashMap::new(),
3656 usage_modes: HashMap::new(),
3657 nominal: HashMap::new(),
3658 coercions: HashMap::new(),
3659 mergers: HashMap::new(),
3660 defaults: HashMap::new(),
3661 policies: HashMap::new(),
3662 outgoing: HashMap::new(),
3663 incoming: HashMap::new(),
3664 between,
3665 };
3666 let src_schema = Schema {
3667 protocol: "test".into(),
3668 vertices: HashMap::new(),
3669 edges: HashMap::new(),
3670 hyper_edges: HashMap::new(),
3671 constraints: HashMap::new(),
3672 required: HashMap::new(),
3673 nsids: HashMap::new(),
3674 entries: Vec::new(),
3675 variants: HashMap::new(),
3676 orderings: HashMap::new(),
3677 recursion_points: HashMap::new(),
3678 spans: HashMap::new(),
3679 usage_modes: HashMap::new(),
3680 nominal: HashMap::new(),
3681 coercions: HashMap::new(),
3682 mergers: HashMap::new(),
3683 defaults: HashMap::new(),
3684 policies: HashMap::new(),
3685 outgoing: HashMap::new(),
3686 incoming: HashMap::new(),
3687 between: HashMap::new(),
3688 };
3689
3690 let predicate = panproto_expr::Expr::Builtin(
3692 panproto_expr::BuiltinOp::Eq,
3693 vec![
3694 panproto_expr::Expr::Var(std::sync::Arc::from("level")),
3695 panproto_expr::Expr::Lit(panproto_expr::Literal::Int(2)),
3696 ],
3697 );
3698
3699 let mut migration = CompiledMigration {
3700 surviving_verts: HashSet::from([Name::from("root"), Name::from("item")]),
3701 surviving_edges: HashSet::from([edge]),
3702 vertex_remap: HashMap::new(),
3703 edge_remap: HashMap::new(),
3704 resolver: HashMap::new(),
3705 hyper_resolver: HashMap::new(),
3706 field_transforms: HashMap::new(),
3707 conditional_survival: HashMap::new(),
3708 op_term_assignments: HashMap::new(),
3709 expansion_path: HashMap::new(),
3710 };
3711 migration.add_conditional_survival("item", predicate);
3712
3713 let result =
3714 wtype_restrict(&inst, &src_schema, &tgt_schema, &migration).expect("restrict ok");
3715
3716 assert_eq!(result.node_count(), 2);
3718 assert!(result.nodes.contains_key(&0));
3719 assert!(result.nodes.contains_key(&1));
3720 assert!(!result.nodes.contains_key(&2));
3721 }
3722
3723 #[test]
3724 #[allow(clippy::expect_used)]
3725 fn conditional_survival_no_predicate_survives() {
3726 use smallvec::smallvec;
3727
3728 let mut nodes = HashMap::new();
3729 nodes.insert(0, Node::new(0, Name::from("root")));
3730 nodes.insert(
3731 1,
3732 Node::new(1, "item").with_extra_field("level", Value::Int(1)),
3733 );
3734
3735 let edge = Edge {
3736 src: "root".into(),
3737 tgt: "item".into(),
3738 kind: "prop".into(),
3739 name: Some("child".into()),
3740 };
3741 let arcs = vec![(0, 1, edge.clone())];
3742 let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("root"));
3743
3744 let mut between = HashMap::new();
3745 between.insert(
3746 (Name::from("root"), Name::from("item")),
3747 smallvec![edge.clone()],
3748 );
3749 let tgt_schema = Schema {
3750 protocol: "test".into(),
3751 vertices: HashMap::new(),
3752 edges: HashMap::new(),
3753 hyper_edges: HashMap::new(),
3754 constraints: HashMap::new(),
3755 required: HashMap::new(),
3756 nsids: HashMap::new(),
3757 entries: Vec::new(),
3758 variants: HashMap::new(),
3759 orderings: HashMap::new(),
3760 recursion_points: HashMap::new(),
3761 spans: HashMap::new(),
3762 usage_modes: HashMap::new(),
3763 nominal: HashMap::new(),
3764 coercions: HashMap::new(),
3765 mergers: HashMap::new(),
3766 defaults: HashMap::new(),
3767 policies: HashMap::new(),
3768 outgoing: HashMap::new(),
3769 incoming: HashMap::new(),
3770 between,
3771 };
3772 let src_schema = Schema {
3773 protocol: "test".into(),
3774 vertices: HashMap::new(),
3775 edges: HashMap::new(),
3776 hyper_edges: HashMap::new(),
3777 constraints: HashMap::new(),
3778 required: HashMap::new(),
3779 nsids: HashMap::new(),
3780 entries: Vec::new(),
3781 variants: HashMap::new(),
3782 orderings: HashMap::new(),
3783 recursion_points: HashMap::new(),
3784 spans: HashMap::new(),
3785 usage_modes: HashMap::new(),
3786 nominal: HashMap::new(),
3787 coercions: HashMap::new(),
3788 mergers: HashMap::new(),
3789 defaults: HashMap::new(),
3790 policies: HashMap::new(),
3791 outgoing: HashMap::new(),
3792 incoming: HashMap::new(),
3793 between: HashMap::new(),
3794 };
3795
3796 let migration = CompiledMigration {
3798 surviving_verts: HashSet::from([Name::from("root"), Name::from("item")]),
3799 surviving_edges: HashSet::from([edge]),
3800 vertex_remap: HashMap::new(),
3801 edge_remap: HashMap::new(),
3802 resolver: HashMap::new(),
3803 hyper_resolver: HashMap::new(),
3804 field_transforms: HashMap::new(),
3805 conditional_survival: HashMap::new(),
3806 op_term_assignments: HashMap::new(),
3807 expansion_path: HashMap::new(),
3808 };
3809
3810 let result =
3811 wtype_restrict(&inst, &src_schema, &tgt_schema, &migration).expect("restrict ok");
3812
3813 assert_eq!(result.node_count(), 2);
3814 assert!(result.nodes.contains_key(&1));
3815 }
3816
3817 #[test]
3820 #[allow(clippy::expect_used)]
3821 fn computed_field_template_name() {
3822 let mut node = Node::new(0, "heading");
3823 node.extra_fields.insert("level".to_string(), Value::Int(2));
3824
3825 let expr = panproto_expr::Expr::Builtin(
3827 panproto_expr::BuiltinOp::Concat,
3828 vec![
3829 panproto_expr::Expr::Lit(panproto_expr::Literal::Str("h".to_string())),
3830 panproto_expr::Expr::Builtin(
3831 panproto_expr::BuiltinOp::IntToStr,
3832 vec![panproto_expr::Expr::Var(std::sync::Arc::from("level"))],
3833 ),
3834 ],
3835 );
3836
3837 let transform = FieldTransform::ComputeField {
3838 target_key: "name".to_string(),
3839 expr,
3840 inverse: None,
3841 coercion_class: panproto_gat::CoercionClass::Opaque,
3842 };
3843 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3844 .expect("transform should evaluate");
3845
3846 assert_eq!(
3847 node.extra_fields.get("name"),
3848 Some(&Value::Str("h2".into()))
3849 );
3850 }
3851
3852 #[test]
3853 #[allow(clippy::expect_used)]
3854 fn computed_field_reads_nested_attrs() {
3855 let mut node = Node::new(0, "heading");
3856 let mut attrs = HashMap::new();
3857 attrs.insert("level".to_string(), Value::Int(3));
3858 node.extra_fields
3859 .insert("attrs".to_string(), Value::Unknown(attrs));
3860
3861 let expr = panproto_expr::Expr::Builtin(
3863 panproto_expr::BuiltinOp::Concat,
3864 vec![
3865 panproto_expr::Expr::Lit(panproto_expr::Literal::Str("h".to_string())),
3866 panproto_expr::Expr::Builtin(
3867 panproto_expr::BuiltinOp::IntToStr,
3868 vec![panproto_expr::Expr::Var(std::sync::Arc::from(
3869 "attrs.level",
3870 ))],
3871 ),
3872 ],
3873 );
3874
3875 let transform = FieldTransform::ComputeField {
3876 target_key: "name".to_string(),
3877 expr,
3878 inverse: None,
3879 coercion_class: panproto_gat::CoercionClass::Opaque,
3880 };
3881 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
3882 .expect("transform should evaluate");
3883
3884 assert_eq!(
3885 node.extra_fields.get("name"),
3886 Some(&Value::Str("h3".into()))
3887 );
3888 }
3889
3890 #[test]
3891 #[allow(clippy::expect_used)]
3892 fn case_transform_sets_field_conditionally() {
3893 use crate::value::Value;
3894 use panproto_expr::{BuiltinOp, Expr, Literal};
3895 use std::sync::Arc;
3896
3897 let mut node = Node::new(0, "heading");
3898 node.extra_fields.insert("level".into(), Value::Int(1));
3899 node.extra_fields
3900 .insert("name".into(), Value::Str("heading".into()));
3901
3902 let case = FieldTransform::Case {
3903 branches: vec![
3904 CaseBranch {
3905 predicate: Expr::builtin(
3906 BuiltinOp::Eq,
3907 vec![Expr::Var(Arc::from("level")), Expr::Lit(Literal::Int(1))],
3908 ),
3909 transforms: vec![FieldTransform::ComputeField {
3910 target_key: "name".into(),
3911 expr: Expr::Lit(Literal::Str("h1".into())),
3912 inverse: None,
3913 coercion_class: panproto_gat::CoercionClass::Opaque,
3914 }],
3915 },
3916 CaseBranch {
3917 predicate: Expr::builtin(
3918 BuiltinOp::Eq,
3919 vec![Expr::Var(Arc::from("level")), Expr::Lit(Literal::Int(2))],
3920 ),
3921 transforms: vec![FieldTransform::ComputeField {
3922 target_key: "name".into(),
3923 expr: Expr::Lit(Literal::Str("h2".into())),
3924 inverse: None,
3925 coercion_class: panproto_gat::CoercionClass::Opaque,
3926 }],
3927 },
3928 ],
3929 };
3930
3931 apply_field_transforms(&mut node, &[case], &TransformContext::detached())
3932 .expect("transform should evaluate");
3933
3934 assert_eq!(
3935 node.extra_fields.get("name"),
3936 Some(&Value::Str("h1".into()))
3937 );
3938 }
3939
3940 fn instance_with_scalar_children() -> (WInstance, HashMap<String, Value>) {
3944 let mut nodes = HashMap::new();
3945 nodes.insert(0, Node::new(0, "body"));
3946 nodes.insert(
3947 1,
3948 Node::new(1, "body.repo").with_value(FieldPresence::Present(Value::Str(
3949 "at://did:plc:abc/app.bsky.feed.post/rkey123".into(),
3950 ))),
3951 );
3952 nodes.insert(
3953 2,
3954 Node::new(2, "body.text")
3955 .with_value(FieldPresence::Present(Value::Str("hello world".into()))),
3956 );
3957
3958 let edge_repo = Edge {
3959 src: "body".into(),
3960 tgt: "body.repo".into(),
3961 kind: "prop".into(),
3962 name: Some("repo".into()),
3963 };
3964 let edge_text = Edge {
3965 src: "body".into(),
3966 tgt: "body.text".into(),
3967 kind: "prop".into(),
3968 name: Some("text".into()),
3969 };
3970
3971 let arcs = vec![(0, 1, edge_repo), (0, 2, edge_text)];
3972 let instance = WInstance::new(nodes, arcs, vec![], 0, "body".into());
3973 let scalars = collect_scalar_child_values(&instance, 0);
3974 (instance, scalars)
3975 }
3976
3977 #[test]
3978 #[allow(clippy::expect_used)]
3979 fn compute_field_reads_scalar_child() {
3980 let (_instance, scalars) = instance_with_scalar_children();
3986 let mut node = Node::new(0, "body");
3987
3988 let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
3989
3990 let transform = FieldTransform::ComputeField {
3991 target_key: "repo_copy".to_string(),
3992 expr,
3993 inverse: None,
3994 coercion_class: panproto_gat::CoercionClass::Projection,
3995 };
3996 apply_field_transforms(
3997 &mut node,
3998 &[transform],
3999 &TransformContext::from_child_values(scalars),
4000 )
4001 .expect("transform should evaluate");
4002
4003 assert_eq!(
4004 node.extra_fields.get("repo_copy"),
4005 Some(&Value::Str(
4006 "at://did:plc:abc/app.bsky.feed.post/rkey123".into()
4007 )),
4008 "ComputeField should read scalar child value via dependent-sum projection"
4009 );
4010 }
4011
4012 #[test]
4013 #[allow(clippy::expect_used)]
4014 fn apply_expr_on_scalar_child() {
4015 let (_instance, scalars) = instance_with_scalar_children();
4016 let mut node = Node::new(0, "body");
4017
4018 let expr = panproto_expr::Expr::Builtin(
4021 panproto_expr::BuiltinOp::Concat,
4022 vec![
4023 panproto_expr::Expr::Var(std::sync::Arc::from("text")),
4024 panproto_expr::Expr::Lit(panproto_expr::Literal::Str("!".into())),
4025 ],
4026 );
4027 let transform = FieldTransform::ApplyExpr {
4028 key: "text".to_string(),
4029 expr,
4030 inverse: None,
4031 coercion_class: panproto_gat::CoercionClass::Projection,
4032 };
4033 apply_field_transforms(
4034 &mut node,
4035 &[transform],
4036 &TransformContext::from_child_values(scalars),
4037 )
4038 .expect("transform should evaluate");
4039
4040 assert_eq!(
4041 node.extra_fields.get("text"),
4042 Some(&Value::Str("hello world!".into())),
4043 "ApplyExpr should read child scalar and write result to extra_fields"
4044 );
4045 }
4046
4047 #[test]
4048 #[allow(clippy::expect_used)]
4049 fn case_branch_on_scalar_child() {
4050 use panproto_expr::{BuiltinOp, Expr, Literal};
4051 use std::sync::Arc;
4052
4053 let (_instance, scalars) = instance_with_scalar_children();
4054 let mut node = Node::new(0, "body");
4055
4056 let case = FieldTransform::Case {
4058 branches: vec![CaseBranch {
4059 predicate: Expr::builtin(
4060 BuiltinOp::Contains,
4061 vec![
4062 Expr::Var(Arc::from("repo")),
4063 Expr::Lit(Literal::Str("did:plc".into())),
4064 ],
4065 ),
4066 transforms: vec![FieldTransform::AddField {
4067 key: "has_did".into(),
4068 value: Value::Bool(true),
4069 }],
4070 }],
4071 };
4072 apply_field_transforms(
4073 &mut node,
4074 &[case],
4075 &TransformContext::from_child_values(scalars),
4076 )
4077 .expect("transform should evaluate");
4078
4079 assert_eq!(
4080 node.extra_fields.get("has_did"),
4081 Some(&Value::Bool(true)),
4082 "Case predicate should evaluate against child scalar values"
4083 );
4084 }
4085
4086 #[test]
4087 #[allow(clippy::expect_used)]
4088 fn drop_field_on_extra_field_still_works() {
4089 let mut node = Node::new(0, "v");
4090 node.extra_fields
4091 .insert("keep".into(), Value::Str("yes".into()));
4092 node.extra_fields
4093 .insert("drop_me".into(), Value::Str("bye".into()));
4094
4095 let transform = FieldTransform::DropField {
4096 key: "drop_me".into(),
4097 };
4098 apply_field_transforms(&mut node, &[transform], &TransformContext::detached())
4099 .expect("transform should evaluate");
4100
4101 assert!(node.extra_fields.contains_key("keep"));
4102 assert!(!node.extra_fields.contains_key("drop_me"));
4103 }
4104
4105 #[test]
4106 #[allow(clippy::expect_used)]
4107 fn child_scalars_do_not_override_extra_fields() {
4108 let mut node = Node::new(0, "v");
4111 node.extra_fields
4112 .insert("repo".into(), Value::Str("from_extra_fields".into()));
4113
4114 let mut child_scalars = HashMap::new();
4115 child_scalars.insert("repo".into(), Value::Str("from_child".into()));
4116
4117 let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
4118 let transform = FieldTransform::ComputeField {
4119 target_key: "repo_copy".to_string(),
4120 expr,
4121 inverse: None,
4122 coercion_class: panproto_gat::CoercionClass::Projection,
4123 };
4124 apply_field_transforms(
4125 &mut node,
4126 &[transform],
4127 &TransformContext::from_child_values(child_scalars.clone()),
4128 )
4129 .expect("transform should evaluate");
4130
4131 assert_eq!(
4132 node.extra_fields.get("repo_copy"),
4133 Some(&Value::Str("from_extra_fields".into())),
4134 "extra_fields must take precedence over child_scalars"
4135 );
4136 }
4137
4138 #[test]
4139 fn collect_scalar_child_values_completeness() {
4140 let (instance, scalars) = instance_with_scalar_children();
4141 assert_eq!(scalars.len(), 2, "should collect both scalar children");
4142 assert_eq!(
4143 scalars.get("repo"),
4144 Some(&Value::Str(
4145 "at://did:plc:abc/app.bsky.feed.post/rkey123".into()
4146 ))
4147 );
4148 assert_eq!(scalars.get("text"), Some(&Value::Str("hello world".into())));
4149
4150 assert!(collect_scalar_child_values(&instance, 99).is_empty());
4152 }
4153
4154 #[test]
4155 fn env_monotonicity() {
4156 let mut extra = HashMap::new();
4159 extra.insert("alpha".into(), Value::Str("a".into()));
4160 extra.insert("beta".into(), Value::Int(42));
4161
4162 let mut children = HashMap::new();
4163 children.insert("gamma".into(), Value::Str("g".into()));
4164 children.insert("delta".into(), Value::Bool(true));
4165
4166 let env_base = build_env_from_extra_fields(&extra);
4167 let env_extended = build_env_with_children(&extra, &children);
4168
4169 let config = panproto_expr::EvalConfig::default();
4171 for key in ["alpha", "beta"] {
4172 let var = panproto_expr::Expr::Var(std::sync::Arc::from(key));
4173 let base_result = panproto_expr::eval(&var, &env_base, &config).ok();
4174 let ext_result = panproto_expr::eval(&var, &env_extended, &config).ok();
4175 assert_eq!(
4176 base_result, ext_result,
4177 "binding for {key} must match between base and extended env"
4178 );
4179 }
4180
4181 for key in ["gamma", "delta"] {
4183 let var = panproto_expr::Expr::Var(std::sync::Arc::from(key));
4184 assert!(
4185 panproto_expr::eval(&var, &env_extended, &config).is_ok(),
4186 "extended env should bind child scalar {key}"
4187 );
4188 }
4189 }
4190
4191 #[test]
4192 #[allow(clippy::expect_used)]
4193 fn compute_field_deterministic() {
4194 let (_instance, scalars) = instance_with_scalar_children();
4197 let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
4198 let transform = FieldTransform::ComputeField {
4199 target_key: "derived".to_string(),
4200 expr,
4201 inverse: None,
4202 coercion_class: panproto_gat::CoercionClass::Projection,
4203 };
4204
4205 let mut node1 = Node::new(0, "body");
4206 apply_field_transforms(
4207 &mut node1,
4208 std::slice::from_ref(&transform),
4209 &TransformContext::from_child_values(scalars.clone()),
4210 )
4211 .expect("transform should evaluate");
4212 let result1 = node1.extra_fields.get("derived").cloned();
4213
4214 let mut node2 = Node::new(0, "body");
4215 apply_field_transforms(
4216 &mut node2,
4217 std::slice::from_ref(&transform),
4218 &TransformContext::from_child_values(scalars),
4219 )
4220 .expect("transform should evaluate");
4221 let result2 = node2.extra_fields.get("derived").cloned();
4222
4223 assert_eq!(result1, result2, "ComputeField must be deterministic");
4224 }
4225
4226 #[test]
4231 #[allow(clippy::expect_used)]
4232 fn field_transform_term_equivalence() {
4233 use panproto_expr::{BuiltinOp, Expr, Literal};
4234 use std::sync::Arc;
4235
4236 fn fixture() -> Node {
4237 let mut node = Node::new(0, "row");
4238 node.extra_fields
4239 .insert("a".into(), Value::Str("hello".into()));
4240 node.extra_fields.insert(
4241 "refs".into(),
4242 Value::List(vec![Value::Str("x".into()), Value::Str("keep".into())]),
4243 );
4244 let mut attrs = HashMap::new();
4245 attrs.insert("k".into(), Value::Int(1));
4246 node.extra_fields
4247 .insert("attrs".into(), Value::Unknown(attrs));
4248 node
4249 }
4250
4251 let variants: Vec<FieldTransform> = vec![
4252 FieldTransform::RenameField {
4253 old_key: "a".into(),
4254 new_key: "b".into(),
4255 },
4256 FieldTransform::DropField { key: "a".into() },
4257 FieldTransform::AddField {
4258 key: "c".into(),
4259 value: Value::Int(7),
4260 },
4261 FieldTransform::KeepFields {
4262 keys: vec!["a".into()],
4263 },
4264 FieldTransform::ApplyExpr {
4265 key: "a".into(),
4266 expr: Expr::Builtin(
4267 BuiltinOp::Concat,
4268 vec![
4269 Expr::Var(Arc::from("a")),
4270 Expr::Lit(Literal::Str("!".into())),
4271 ],
4272 ),
4273 inverse: None,
4274 coercion_class: panproto_gat::CoercionClass::Opaque,
4275 },
4276 FieldTransform::ComputeField {
4277 target_key: "d".into(),
4278 expr: Expr::Var(Arc::from("a")),
4279 inverse: None,
4280 coercion_class: panproto_gat::CoercionClass::Projection,
4281 },
4282 FieldTransform::PathTransform {
4283 path: vec!["attrs".into()],
4284 inner: Box::new(FieldTransform::RenameField {
4285 old_key: "k".into(),
4286 new_key: "kk".into(),
4287 }),
4288 },
4289 FieldTransform::MapReferences {
4290 field: "refs".into(),
4291 rename_map: HashMap::from([("x".to_string(), Some("y".to_string()))]),
4292 },
4293 FieldTransform::Case {
4294 branches: vec![CaseBranch {
4295 predicate: Expr::Lit(Literal::Bool(true)),
4296 transforms: vec![FieldTransform::AddField {
4297 key: "flag".into(),
4298 value: Value::Bool(true),
4299 }],
4300 }],
4301 },
4302 ];
4303
4304 let apply = |ft: &FieldTransform| {
4305 let mut node = fixture();
4306 let ctx = TransformContext::detached();
4307 apply_field_transforms(&mut node, std::slice::from_ref(ft), &ctx)
4308 .expect("transform should evaluate");
4309 node
4310 };
4311
4312 for ft in &variants {
4313 let direct = apply(ft);
4314
4315 let assignment = TermAssignment::from_field_transform(ft);
4318 let via_term = apply(&assignment.to_field_transform());
4319
4320 assert_eq!(
4321 direct.extra_fields, via_term.extra_fields,
4322 "term-assignment path must match direct field transform for {ft:?}",
4323 );
4324
4325 let mut row = fixture().extra_fields;
4328 apply_term_assignments_to_row(&mut row, std::slice::from_ref(&assignment))
4329 .expect("transform should evaluate");
4330 assert_eq!(
4331 row, direct.extra_fields,
4332 "flat-row term substitution must match for {ft:?}",
4333 );
4334 }
4335 }
4336
4337 #[cfg(test)]
4340 #[allow(clippy::unwrap_used)]
4341 mod property {
4342 use super::*;
4343 use proptest::prelude::*;
4344
4345 fn arb_instance_with_scalars()
4348 -> impl Strategy<Value = (WInstance, HashMap<String, Value>, Vec<String>)> {
4349 (1..=5usize).prop_flat_map(|n| {
4350 prop::collection::vec("[a-z]{1,8}".prop_map(String::from), n..=n).prop_flat_map(
4351 move |values| {
4352 prop::collection::vec("[a-z]{1,6}".prop_map(String::from), n..=n).prop_map(
4353 move |names| {
4354 let values = values.clone();
4355 let mut seen = std::collections::HashSet::new();
4357 let deduped: Vec<String> = names
4358 .iter()
4359 .map(|name| {
4360 let mut candidate = name.clone();
4361 let mut i = 0;
4362 while seen.contains(&candidate) {
4363 candidate = format!("{name}{i}");
4364 i += 1;
4365 }
4366 seen.insert(candidate.clone());
4367 candidate
4368 })
4369 .collect();
4370
4371 let mut nodes = HashMap::new();
4372 nodes.insert(0, Node::new(0, "root"));
4373
4374 let mut arcs = Vec::new();
4375 for (i, (name, val)) in
4376 deduped.iter().zip(values.iter()).enumerate()
4377 {
4378 let nid = u32::try_from(i + 1).unwrap();
4379 let anchor = format!("root.{name}");
4380 nodes.insert(
4381 nid,
4382 Node::new(nid, anchor.as_str()).with_value(
4383 FieldPresence::Present(Value::Str(val.clone())),
4384 ),
4385 );
4386 arcs.push((
4387 0,
4388 nid,
4389 Edge {
4390 src: "root".into(),
4391 tgt: Name::from(anchor.as_str()),
4392 kind: "prop".into(),
4393 name: Some(Name::from(name.as_str())),
4394 },
4395 ));
4396 }
4397
4398 let instance =
4399 WInstance::new(nodes, arcs, vec![], 0, "root".into());
4400 let scalars = collect_scalar_child_values(&instance, 0);
4401 (instance, scalars, deduped)
4402 },
4403 )
4404 },
4405 )
4406 })
4407 }
4408
4409 proptest! {
4410 #![proptest_config(ProptestConfig::with_cases(128))]
4411
4412 #[test]
4413 fn prop_child_scalar_collection_complete(
4414 (_instance, scalars, names) in arb_instance_with_scalars()
4415 ) {
4416 for name in &names {
4418 prop_assert!(
4419 scalars.contains_key(name),
4420 "child scalar {name} missing from collection"
4421 );
4422 }
4423 prop_assert_eq!(
4424 scalars.len(), names.len(),
4425 "scalar count must match child count"
4426 );
4427 }
4428
4429 #[test]
4430 #[allow(clippy::expect_used)]
4431 fn prop_compute_field_reads_any_child(
4432 (_instance, scalars, names) in arb_instance_with_scalars()
4433 ) {
4434 for name in &names {
4436 let expr = panproto_expr::Expr::Var(std::sync::Arc::from(name.as_str()));
4437 let transform = FieldTransform::ComputeField {
4438 target_key: format!("{name}_copy"),
4439 expr,
4440 inverse: None,
4441 coercion_class: panproto_gat::CoercionClass::Projection,
4442 };
4443 let mut node = Node::new(0, "root");
4444 apply_field_transforms(&mut node, &[transform], &TransformContext::from_child_values(scalars.clone())).expect("transform should evaluate");
4445 let expected = scalars.get(name);
4446 let actual = node.extra_fields.get(&format!("{name}_copy"));
4447 prop_assert_eq!(
4448 actual, expected,
4449 "ComputeField should read child scalar"
4450 );
4451 }
4452 }
4453
4454 #[test]
4455 fn prop_env_monotonicity(
4456 (_instance, scalars, _names) in arb_instance_with_scalars()
4457 ) {
4458 let mut extra = HashMap::new();
4461 extra.insert("sentinel".into(), Value::Str("sentinel_val".into()));
4462
4463 let env_base = build_env_from_extra_fields(&extra);
4464 let env_extended = build_env_with_children(&extra, &scalars);
4465
4466 let var = panproto_expr::Expr::Var(std::sync::Arc::from("sentinel"));
4467 let config = panproto_expr::EvalConfig::default();
4468 let base_result = panproto_expr::eval(&var, &env_base, &config).ok();
4469 let ext_result = panproto_expr::eval(&var, &env_extended, &config).ok();
4470 prop_assert_eq!(
4471 base_result, ext_result,
4472 "existing extra_field binding must be preserved"
4473 );
4474 }
4475
4476 #[test]
4477 fn prop_identity_restrict_preserves_all_values(
4478 (instance, _scalars, _names) in arb_instance_with_scalars()
4479 ) {
4480 use smallvec::SmallVec;
4483
4484 let mut vertices = HashMap::new();
4485 let mut edges_map = HashMap::new();
4486 let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
4487 let mut incoming: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
4488 let mut between: HashMap<(Name, Name), SmallVec<Edge, 2>> = HashMap::new();
4489
4490 for node in instance.nodes.values() {
4491 vertices.insert(
4492 node.anchor.clone(),
4493 panproto_schema::Vertex {
4494 id: node.anchor.clone(),
4495 kind: if node.value.is_some() { "string".into() } else { "object".into() },
4496 nsid: None,
4497 },
4498 );
4499 }
4500 for (p, c, e) in &instance.arcs {
4501 let _ = p;
4502 let _ = c;
4503 edges_map.insert(e.clone(), e.kind.clone());
4504 outgoing.entry(e.src.clone()).or_default().push(e.clone());
4505 incoming.entry(e.tgt.clone()).or_default().push(e.clone());
4506 between.entry((e.src.clone(), e.tgt.clone())).or_default().push(e.clone());
4507 }
4508
4509 let schema = panproto_schema::Schema {
4510 protocol: "test".into(),
4511 vertices,
4512 edges: edges_map,
4513 hyper_edges: HashMap::new(),
4514 constraints: HashMap::new(),
4515 required: HashMap::new(),
4516 nsids: HashMap::new(),
4517 entries: Vec::new(),
4518 variants: HashMap::new(),
4519 orderings: HashMap::new(),
4520 recursion_points: HashMap::new(),
4521 spans: HashMap::new(),
4522 usage_modes: HashMap::new(),
4523 nominal: HashMap::new(),
4524 coercions: HashMap::new(),
4525 mergers: HashMap::new(),
4526 defaults: HashMap::new(),
4527 policies: HashMap::new(),
4528 outgoing,
4529 incoming,
4530 between,
4531 };
4532
4533 let surviving_verts = schema.vertices.keys().cloned().collect();
4534 let surviving_edges = schema.edges.keys().cloned().collect();
4535 let migration = CompiledMigration {
4536 surviving_verts,
4537 surviving_edges,
4538 vertex_remap: HashMap::new(),
4539 edge_remap: HashMap::new(),
4540 resolver: HashMap::new(),
4541 hyper_resolver: HashMap::new(),
4542 field_transforms: HashMap::new(),
4543 conditional_survival: HashMap::new(),
4544 op_term_assignments: HashMap::new(),
4545 expansion_path: HashMap::new(),
4546 };
4547
4548 let result = wtype_restrict(&instance, &schema, &schema, &migration);
4549 prop_assert!(result.is_ok(), "identity restrict should succeed");
4550 let restricted = result.unwrap();
4551 prop_assert_eq!(
4552 restricted.node_count(), instance.node_count(),
4553 "identity restrict must preserve node count"
4554 );
4555 for (&id, node) in &instance.nodes {
4556 let r_node = restricted.nodes.get(&id).unwrap();
4557 prop_assert_eq!(&node.anchor, &r_node.anchor);
4558 prop_assert_eq!(&node.value, &r_node.value);
4559 prop_assert_eq!(&node.extra_fields, &r_node.extra_fields);
4560 }
4561 }
4562 }
4563 }
4564}