use std::collections::{HashMap, HashSet, VecDeque};
use panproto_gat::Name;
use panproto_schema::{Edge, Schema};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use crate::error::RestrictError;
use crate::fan::Fan;
use crate::metadata::Node;
use crate::value::Value;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CompiledMigration {
pub surviving_verts: HashSet<Name>,
pub surviving_edges: HashSet<Edge>,
pub vertex_remap: HashMap<Name, Name>,
pub edge_remap: HashMap<Edge, Edge>,
pub resolver: HashMap<(Name, Name), Edge>,
pub hyper_resolver: HashMap<Name, (Name, HashMap<Name, Name>)>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub field_transforms: HashMap<Name, Vec<FieldTransform>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub conditional_survival: HashMap<Name, panproto_expr::Expr>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub op_term_assignments: HashMap<Name, Vec<TermAssignment>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub expansion_path: HashMap<(Name, Name), Vec<Name>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum FieldTransform {
RenameField {
old_key: String,
new_key: String,
},
DropField {
key: String,
},
AddField {
key: String,
value: Value,
},
KeepFields {
keys: Vec<String>,
},
ApplyExpr {
key: String,
expr: panproto_expr::Expr,
inverse: Option<panproto_expr::Expr>,
coercion_class: panproto_gat::CoercionClass,
},
PathTransform {
path: Vec<String>,
inner: Box<Self>,
},
ComputeField {
target_key: String,
expr: panproto_expr::Expr,
inverse: Option<panproto_expr::Expr>,
coercion_class: panproto_gat::CoercionClass,
},
Case {
branches: Vec<CaseBranch>,
},
MapReferences {
field: String,
rename_map: HashMap<String, Option<String>>,
},
}
impl FieldTransform {
#[must_use]
pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
match self {
Self::RenameField { .. } => panproto_gat::CoercionClass::Iso,
Self::DropField { .. } | Self::KeepFields { .. } => panproto_gat::CoercionClass::Opaque,
Self::AddField { .. } | Self::MapReferences { .. } => {
panproto_gat::CoercionClass::Retraction
}
Self::ApplyExpr { coercion_class, .. } | Self::ComputeField { coercion_class, .. } => {
*coercion_class
}
Self::PathTransform { inner, .. } => inner.coercion_class(),
Self::Case { branches } => branches
.iter()
.flat_map(|b| b.transforms.iter())
.fold(panproto_gat::CoercionClass::Iso, |acc, t| {
acc.compose(t.coercion_class())
}),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CaseBranch {
pub predicate: panproto_expr::Expr,
pub transforms: Vec<FieldTransform>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TermScope {
Field,
Row,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TermBranch {
pub predicate: panproto_expr::Expr,
pub assignments: Vec<TermAssignment>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TermAssignment {
Compute {
target: String,
scope: TermScope,
term: panproto_expr::Expr,
inverse: Option<panproto_expr::Expr>,
coercion_class: panproto_gat::CoercionClass,
},
Rename {
old: String,
new: String,
},
Drop {
key: String,
},
Default {
key: String,
value: Value,
},
Keep {
keys: Vec<String>,
},
MapReferences {
field: String,
rename_map: HashMap<String, Option<String>>,
},
AtPath {
path: Vec<String>,
inner: Box<Self>,
},
Case {
branches: Vec<TermBranch>,
},
}
impl TermAssignment {
#[must_use]
pub fn from_field_transform(ft: &FieldTransform) -> Self {
match ft {
FieldTransform::RenameField { old_key, new_key } => Self::Rename {
old: old_key.clone(),
new: new_key.clone(),
},
FieldTransform::DropField { key } => Self::Drop { key: key.clone() },
FieldTransform::AddField { key, value } => Self::Default {
key: key.clone(),
value: value.clone(),
},
FieldTransform::KeepFields { keys } => Self::Keep { keys: keys.clone() },
FieldTransform::ApplyExpr {
key,
expr,
inverse,
coercion_class,
} => Self::Compute {
target: key.clone(),
scope: TermScope::Field,
term: expr.clone(),
inverse: inverse.clone(),
coercion_class: *coercion_class,
},
FieldTransform::ComputeField {
target_key,
expr,
inverse,
coercion_class,
} => Self::Compute {
target: target_key.clone(),
scope: TermScope::Row,
term: expr.clone(),
inverse: inverse.clone(),
coercion_class: *coercion_class,
},
FieldTransform::PathTransform { path, inner } => Self::AtPath {
path: path.clone(),
inner: Box::new(Self::from_field_transform(inner)),
},
FieldTransform::MapReferences { field, rename_map } => Self::MapReferences {
field: field.clone(),
rename_map: rename_map.clone(),
},
FieldTransform::Case { branches } => Self::Case {
branches: branches
.iter()
.map(|b| TermBranch {
predicate: b.predicate.clone(),
assignments: b
.transforms
.iter()
.map(Self::from_field_transform)
.collect(),
})
.collect(),
},
}
}
#[must_use]
pub fn to_field_transform(&self) -> FieldTransform {
match self {
Self::Rename { old, new } => FieldTransform::RenameField {
old_key: old.clone(),
new_key: new.clone(),
},
Self::Drop { key } => FieldTransform::DropField { key: key.clone() },
Self::Default { key, value } => FieldTransform::AddField {
key: key.clone(),
value: value.clone(),
},
Self::Keep { keys } => FieldTransform::KeepFields { keys: keys.clone() },
Self::Compute {
target,
scope: TermScope::Field,
term,
inverse,
coercion_class,
} => FieldTransform::ApplyExpr {
key: target.clone(),
expr: term.clone(),
inverse: inverse.clone(),
coercion_class: *coercion_class,
},
Self::Compute {
target,
scope: TermScope::Row,
term,
inverse,
coercion_class,
} => FieldTransform::ComputeField {
target_key: target.clone(),
expr: term.clone(),
inverse: inverse.clone(),
coercion_class: *coercion_class,
},
Self::MapReferences { field, rename_map } => FieldTransform::MapReferences {
field: field.clone(),
rename_map: rename_map.clone(),
},
Self::AtPath { path, inner } => FieldTransform::PathTransform {
path: path.clone(),
inner: Box::new(inner.to_field_transform()),
},
Self::Case { branches } => FieldTransform::Case {
branches: branches
.iter()
.map(|b| CaseBranch {
predicate: b.predicate.clone(),
transforms: b.assignments.iter().map(Self::to_field_transform).collect(),
})
.collect(),
},
}
}
#[must_use]
pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
self.to_field_transform().coercion_class()
}
}
pub fn apply_term_assignments_to_row(
row: &mut HashMap<String, Value>,
assignments: &[TermAssignment],
) -> Result<(), RestrictError> {
if assignments.is_empty() {
return Ok(());
}
let transforms: Vec<FieldTransform> = assignments
.iter()
.map(TermAssignment::to_field_transform)
.collect();
let mut node = Node::new(0, "");
node.extra_fields = std::mem::take(row);
let outcome = apply_field_transforms(&mut node, &transforms, &HashMap::new());
*row = node.extra_fields;
outcome
}
impl CompiledMigration {
#[must_use]
pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
let from_fields = self
.field_transforms
.values()
.flat_map(|ts| ts.iter())
.fold(panproto_gat::CoercionClass::Iso, |acc, t| {
acc.compose(t.coercion_class())
});
self.op_term_assignments
.values()
.flat_map(|ts| ts.iter())
.fold(from_fields, |acc, t| acc.compose(t.coercion_class()))
}
#[must_use]
pub fn value_transforms(&self, anchor: &Name) -> Vec<FieldTransform> {
let mut out: Vec<FieldTransform> = self
.field_transforms
.get(anchor)
.cloned()
.unwrap_or_default();
if let Some(assignments) = self.op_term_assignments.get(anchor) {
out.extend(assignments.iter().map(TermAssignment::to_field_transform));
}
out
}
pub fn add_field_rename(&mut self, vertex: &str, old_key: &str, new_key: &str) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::RenameField {
old_key: old_key.to_owned(),
new_key: new_key.to_owned(),
});
}
pub fn add_field_drop(&mut self, vertex: &str, key: &str) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::DropField {
key: key.to_owned(),
});
}
pub fn add_field_default(&mut self, vertex: &str, key: &str, value: Value) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::AddField {
key: key.to_owned(),
value,
});
}
pub fn add_field_keep(&mut self, vertex: &str, keys: &[&str]) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::KeepFields {
keys: keys.iter().map(|k| (*k).to_owned()).collect(),
});
}
pub fn add_field_expr(&mut self, vertex: &str, key: &str, expr: panproto_expr::Expr) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::ApplyExpr {
key: key.to_owned(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Opaque,
});
}
pub fn add_path_transform(&mut self, vertex: &str, path: &[&str], inner: FieldTransform) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::PathTransform {
path: path.iter().map(|s| (*s).to_owned()).collect(),
inner: Box::new(inner),
});
}
pub fn add_computed_field(
&mut self,
vertex: &str,
target_key: &str,
expr: panproto_expr::Expr,
) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::ComputeField {
target_key: target_key.to_owned(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Opaque,
});
}
pub fn add_conditional_survival(&mut self, vertex: &str, predicate: panproto_expr::Expr) {
self.conditional_survival
.entry(Name::from(vertex))
.or_insert(predicate);
}
pub fn add_map_references(
&mut self,
vertex: &str,
field: &str,
rename_map: HashMap<String, Option<String>>,
) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::MapReferences {
field: field.to_owned(),
rename_map,
});
}
pub fn add_case_transform(&mut self, vertex: &str, branches: Vec<CaseBranch>) {
self.field_transforms
.entry(Name::from(vertex))
.or_default()
.push(FieldTransform::Case { branches });
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WInstance {
pub nodes: HashMap<u32, Node>,
pub arcs: Vec<(u32, u32, Edge)>,
pub fans: Vec<Fan>,
pub root: u32,
pub schema_root: Name,
pub parent_map: HashMap<u32, u32>,
pub children_map: HashMap<u32, SmallVec<u32, 4>>,
}
impl WInstance {
#[must_use]
pub fn new(
nodes: HashMap<u32, Node>,
arcs: Vec<(u32, u32, Edge)>,
fans: Vec<Fan>,
root: u32,
schema_root: Name,
) -> Self {
let mut parent_map = HashMap::with_capacity(arcs.len());
let mut children_map: HashMap<u32, SmallVec<u32, 4>> = HashMap::new();
for &(parent, child, _) in &arcs {
parent_map.insert(child, parent);
children_map.entry(parent).or_default().push(child);
}
Self {
nodes,
arcs,
fans,
root,
schema_root,
parent_map,
children_map,
}
}
#[inline]
#[must_use]
pub fn node_count(&self) -> usize {
self.nodes.len()
}
#[inline]
#[must_use]
pub fn arc_count(&self) -> usize {
self.arcs.len()
}
#[inline]
#[must_use]
pub fn node(&self, id: u32) -> Option<&Node> {
self.nodes.get(&id)
}
#[inline]
#[must_use]
pub fn children(&self, id: u32) -> &[u32] {
self.children_map.get(&id).map_or(&[], SmallVec::as_slice)
}
#[inline]
#[must_use]
pub fn parent(&self, id: u32) -> Option<u32> {
self.parent_map.get(&id).copied()
}
}
#[must_use]
pub fn anchor_surviving(instance: &WInstance, surviving_verts: &HashSet<Name>) -> HashSet<u32> {
instance
.nodes
.iter()
.filter(|(_, node)| surviving_verts.contains(&node.anchor))
.map(|(&id, _)| id)
.collect()
}
#[must_use]
pub fn ancestor_contraction(instance: &WInstance, surviving: &HashSet<u32>) -> HashMap<u32, u32> {
let mut cache: FxHashMap<u32, u32> = FxHashMap::default();
let mut ancestors = HashMap::new();
for &node_id in surviving {
if node_id == instance.root {
continue;
}
if let Some(&cached) = cache.get(&node_id) {
ancestors.insert(node_id, cached);
continue;
}
let mut path = Vec::new();
let mut current = node_id;
let mut found_ancestor = None;
while let Some(parent) = instance.parent(current) {
if let Some(&cached) = cache.get(&parent) {
found_ancestor = Some(cached);
break;
}
if surviving.contains(&parent) {
found_ancestor = Some(parent);
break;
}
path.push(parent);
current = parent;
}
if let Some(ancestor) = found_ancestor {
ancestors.insert(node_id, ancestor);
cache.insert(node_id, ancestor);
for &intermediate in &path {
cache.insert(intermediate, ancestor);
}
}
}
ancestors
}
pub fn resolve_edge(
tgt_schema: &Schema,
resolver: &HashMap<(Name, Name), Edge>,
src_v: &str,
tgt_v: &str,
) -> Result<Edge, RestrictError> {
for ((k_src, k_tgt), edge) in resolver {
if k_src == src_v && k_tgt == tgt_v {
return Ok(edge.clone());
}
}
let candidates = tgt_schema.edges_between(src_v, tgt_v);
match candidates.len() {
0 => Err(RestrictError::NoEdgeFound {
src: src_v.to_string(),
tgt: tgt_v.to_string(),
}),
1 => Ok(candidates[0].clone()),
n => Err(RestrictError::AmbiguousEdge {
src: src_v.to_string(),
tgt: tgt_v.to_string(),
count: n,
}),
}
}
pub fn reconstruct_fans(
instance: &WInstance,
surviving: &FxHashSet<u32>,
_ancestors: &FxHashMap<u32, u32>,
migration: &CompiledMigration,
_tgt_schema: &Schema,
) -> Result<Vec<Fan>, RestrictError> {
let mut result = Vec::new();
for fan in &instance.fans {
if !surviving.contains(&fan.parent) {
continue;
}
let surviving_children: HashMap<String, u32> = fan
.children
.iter()
.filter(|(_, node_id)| surviving.contains(node_id))
.map(|(label, node_id)| (label.clone(), *node_id))
.collect();
if surviving_children.is_empty() {
continue;
}
if let Some((new_he_id, label_map)) =
migration.hyper_resolver.get(fan.hyper_edge_id.as_str())
{
let mut new_children = HashMap::new();
for (old_label, &node_id) in &surviving_children {
let new_label = label_map
.get(old_label.as_str())
.map_or_else(|| old_label.clone(), std::string::ToString::to_string);
new_children.insert(new_label, node_id);
}
result.push(Fan {
hyper_edge_id: new_he_id.to_string(),
parent: fan.parent,
children: new_children,
});
} else {
result.push(Fan {
hyper_edge_id: fan.hyper_edge_id.clone(),
parent: fan.parent,
children: surviving_children,
});
}
}
Ok(result)
}
pub fn wtype_restrict(
instance: &WInstance,
_src_schema: &Schema,
tgt_schema: &Schema,
migration: &CompiledMigration,
) -> Result<WInstance, RestrictError> {
let root_node = instance
.nodes
.get(&instance.root)
.ok_or(RestrictError::RootPruned)?;
let root_target_anchor = migration
.vertex_remap
.get(&root_node.anchor)
.unwrap_or(&root_node.anchor);
if !migration.surviving_verts.contains(root_target_anchor) {
return Err(RestrictError::RootPruned);
}
let conditional_fail = precompute_conditional_fail(instance, migration);
let mut new_nodes: HashMap<u32, Node> = HashMap::new();
let mut new_arcs: Vec<(u32, u32, Edge)> = Vec::new();
let mut surviving_set: FxHashSet<u32> = FxHashSet::default();
let mut next_synth_id: u32 = instance
.nodes
.keys()
.copied()
.max()
.map_or(0, |m| m.saturating_add(1));
let mut queue: VecDeque<(u32, Option<u32>)> = VecDeque::new();
let root_node_cloned = prepare_root_node(root_node, migration, instance)?;
new_nodes.insert(instance.root, root_node_cloned);
surviving_set.insert(instance.root);
queue.push_back((instance.root, None));
while let Some((current_id, ancestor_id)) = queue.pop_front() {
let current_survives = surviving_set.contains(¤t_id);
let child_ancestor = if current_survives {
Some(current_id)
} else {
ancestor_id
};
for &child_id in instance.children(current_id) {
let Some(child_node) = instance.nodes.get(&child_id) else {
continue;
};
let target_anchor = migration
.vertex_remap
.get(&child_node.anchor)
.unwrap_or(&child_node.anchor);
if migration.surviving_verts.contains(target_anchor)
&& !conditional_fail.contains(&child_id)
{
surviving_set.insert(child_id);
let mut new_node = child_node.clone();
if let Some(remapped) = migration.vertex_remap.get(&child_node.anchor) {
new_node.anchor.clone_from(remapped);
}
let transforms = migration.value_transforms(&child_node.anchor);
if !transforms.is_empty() {
let scalars = collect_scalar_child_values(instance, child_id);
apply_field_transforms(&mut new_node, &transforms, &scalars)?;
}
new_nodes.insert(child_id, new_node.clone());
if let Some(anc_id) = child_ancestor {
connect_ancestor_to_child(
anc_id,
child_id,
&new_node.anchor,
&mut new_nodes,
&mut new_arcs,
&mut surviving_set,
&mut next_synth_id,
migration,
tgt_schema,
)?;
}
}
queue.push_back((child_id, child_ancestor));
}
}
let fused_surviving = &surviving_set;
let empty_ancestors = FxHashMap::default();
let new_fans = reconstruct_fans(
instance,
fused_surviving,
&empty_ancestors,
migration,
tgt_schema,
)?;
let new_schema_root = migration
.vertex_remap
.get(&instance.schema_root)
.cloned()
.unwrap_or_else(|| instance.schema_root.clone());
Ok(WInstance::new(
new_nodes,
new_arcs,
new_fans,
instance.root,
new_schema_root,
))
}
fn precompute_conditional_fail(
instance: &WInstance,
migration: &CompiledMigration,
) -> FxHashSet<u32> {
if migration.conditional_survival.is_empty() {
return FxHashSet::default();
}
instance
.nodes
.iter()
.filter_map(|(&id, node)| {
let pred = migration.conditional_survival.get(&node.anchor)?;
let env = build_env_from_extra_fields(&node.extra_fields);
let config = panproto_expr::EvalConfig::default();
matches!(
panproto_expr::eval(pred, &env, &config),
Ok(panproto_expr::Literal::Bool(false))
)
.then_some(id)
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn connect_ancestor_to_child(
anc_id: u32,
child_id: u32,
child_anchor: &Name,
new_nodes: &mut HashMap<u32, Node>,
new_arcs: &mut Vec<(u32, u32, Edge)>,
surviving_set: &mut FxHashSet<u32>,
next_synth_id: &mut u32,
migration: &CompiledMigration,
tgt_schema: &Schema,
) -> Result<(), RestrictError> {
let anc_anchor = new_nodes
.get(&anc_id)
.ok_or(RestrictError::RootPruned)?
.anchor
.clone();
let child_anchor = child_anchor.clone();
match resolve_edge(tgt_schema, &migration.resolver, &anc_anchor, &child_anchor) {
Ok(edge) => {
new_arcs.push((anc_id, child_id, edge));
Ok(())
}
Err(restrict_err) => {
let Some(intermediates) = migration
.expansion_path
.get(&(anc_anchor.clone(), child_anchor.clone()))
else {
return Err(restrict_err);
};
let mut prev_id = anc_id;
let mut prev_anchor = anc_anchor;
for intermediate_anchor in intermediates {
let synth_id = *next_synth_id;
*next_synth_id = next_synth_id.saturating_add(1);
let synth_node = Node::new(synth_id, intermediate_anchor.clone());
new_nodes.insert(synth_id, synth_node);
surviving_set.insert(synth_id);
let edge = resolve_edge(
tgt_schema,
&migration.resolver,
&prev_anchor,
intermediate_anchor,
)?;
new_arcs.push((prev_id, synth_id, edge));
prev_id = synth_id;
prev_anchor = intermediate_anchor.clone();
}
let final_edge =
resolve_edge(tgt_schema, &migration.resolver, &prev_anchor, &child_anchor)?;
new_arcs.push((prev_id, child_id, final_edge));
Ok(())
}
}
}
pub fn apply_field_transforms(
node: &mut Node,
transforms: &[FieldTransform],
child_scalars: &HashMap<String, Value>,
) -> Result<(), RestrictError> {
for transform in transforms {
match transform {
FieldTransform::RenameField { old_key, new_key } => {
if let Some(val) = node.extra_fields.remove(old_key) {
node.extra_fields.insert(new_key.clone(), val);
}
}
FieldTransform::DropField { key } => {
node.extra_fields.remove(key);
}
FieldTransform::AddField { key, value } => {
node.extra_fields
.entry(key.clone())
.or_insert_with(|| value.clone());
}
FieldTransform::KeepFields { keys } => {
node.extra_fields.retain(|k, _| keys.contains(k));
}
FieldTransform::ApplyExpr { key, expr, .. } => {
if key == "__value__" {
if let Some(crate::value::FieldPresence::Present(val)) = &node.value {
let input = value_to_expr_literal(val);
let env = panproto_expr::Env::new()
.extend(std::sync::Arc::from("v"), input.clone())
.extend(std::sync::Arc::from("__value__"), input);
let config = panproto_expr::EvalConfig::default();
let result =
panproto_expr::eval(expr, &env, &config).map_err(|source| {
RestrictError::FieldTransformFailed {
key: key.clone(),
source,
}
})?;
node.value = Some(crate::value::FieldPresence::Present(
expr_literal_to_value(&result),
));
}
} else if let Some(val) = node
.extra_fields
.get(key)
.or_else(|| child_scalars.get(key))
{
let input = value_to_expr_literal(val);
let env =
panproto_expr::Env::new().extend(std::sync::Arc::from(key.as_str()), input);
let config = panproto_expr::EvalConfig::default();
let result = panproto_expr::eval(expr, &env, &config).map_err(|source| {
RestrictError::FieldTransformFailed {
key: key.clone(),
source,
}
})?;
node.extra_fields
.insert(key.clone(), expr_literal_to_value(&result));
}
}
FieldTransform::ComputeField {
target_key, expr, ..
} => {
let env = build_env_with_children(&node.extra_fields, child_scalars);
let config = panproto_expr::EvalConfig::default();
let result = panproto_expr::eval(expr, &env, &config).map_err(|source| {
RestrictError::FieldTransformFailed {
key: target_key.clone(),
source,
}
})?;
node.extra_fields
.insert(target_key.clone(), expr_literal_to_value(&result));
}
FieldTransform::PathTransform { path, inner } => {
if path.is_empty() {
apply_field_transforms(node, std::slice::from_ref(inner), &HashMap::new())?;
} else {
apply_path_transform(node, path, inner)?;
}
}
FieldTransform::MapReferences { field, rename_map } => {
apply_map_references(node, field, rename_map);
}
FieldTransform::Case { branches } => {
let env = build_env_with_children(&node.extra_fields, child_scalars);
let config = panproto_expr::EvalConfig::default();
for (index, branch) in branches.iter().enumerate() {
let result = panproto_expr::eval(&branch.predicate, &env, &config).map_err(
|source| RestrictError::FieldTransformFailed {
key: format!("<case branch {index}>"),
source,
},
)?;
if matches!(result, panproto_expr::Literal::Bool(true)) {
apply_field_transforms(node, &branch.transforms, child_scalars)?;
break;
}
}
}
}
}
Ok(())
}
fn apply_path_transform(
node: &mut Node,
path: &[String],
inner: &FieldTransform,
) -> Result<(), RestrictError> {
let first = &path[0];
let Some(Value::Unknown(map)) = node.extra_fields.get_mut(first) else {
return Ok(());
};
let mut temp_node = Node::new(0, "");
temp_node.extra_fields = std::mem::take(map);
let outcome = if path.len() == 1 {
apply_field_transforms(&mut temp_node, std::slice::from_ref(inner), &HashMap::new())
} else {
apply_path_transform(&mut temp_node, &path[1..], inner)
};
if let Some(Value::Unknown(slot)) = node.extra_fields.get_mut(first) {
*slot = temp_node.extra_fields;
}
outcome
}
fn apply_map_references(
node: &mut Node,
field: &str,
rename_map: &HashMap<String, Option<String>>,
) {
if let Some(val) = node.extra_fields.get_mut(field) {
match val {
Value::Str(s) => {
if let Some(replacement) = rename_map.get(s.as_str()) {
match replacement {
Some(new_name) => *s = new_name.clone(),
None => {
node.extra_fields.remove(field);
}
}
}
}
Value::List(items) => {
let mut new_items = Vec::with_capacity(items.len());
for item in items.iter() {
match item {
Value::Str(s) => match rename_map.get(s.as_str()) {
Some(Some(new_name)) => {
new_items.push(Value::Str(new_name.clone()));
}
Some(None) => {} None => new_items.push(Value::Str(s.clone())),
},
other => new_items.push(other.clone()),
}
}
*items = new_items;
}
_ => {}
}
}
}
#[must_use]
pub fn collect_scalar_child_values(instance: &WInstance, node_id: u32) -> HashMap<String, Value> {
let mut result = HashMap::new();
for &(parent, child, ref edge) in &instance.arcs {
if parent != node_id {
continue;
}
let Some(child_node) = instance.nodes.get(&child) else {
continue;
};
if let Some(crate::value::FieldPresence::Present(val)) = &child_node.value {
let field_name = edge.name.as_deref().unwrap_or(&*edge.tgt);
result.insert(field_name.to_string(), val.clone());
}
}
result
}
#[must_use]
pub fn build_env_with_children(
fields: &HashMap<String, Value>,
child_scalars: &HashMap<String, Value>,
) -> panproto_expr::Env {
let mut combined = child_scalars.clone();
for (key, val) in fields {
combined.insert(key.clone(), val.clone());
}
build_env_from_extra_fields(&combined)
}
#[must_use]
pub fn build_env_from_extra_fields(fields: &HashMap<String, Value>) -> panproto_expr::Env {
let mut env = panproto_expr::Env::new();
for (key, val) in fields {
let lit = value_to_expr_literal(val);
env = env.extend(std::sync::Arc::from(key.as_str()), lit.clone());
if key != "attrs" && key != "name" && key != "$type" && key != "parents" {
let qualified = format!("attrs.{key}");
env = env.extend(std::sync::Arc::from(qualified.as_str()), lit);
}
}
if let Some(Value::Unknown(attrs)) = fields.get("attrs") {
for (key, val) in attrs {
let lit = value_to_expr_literal(val);
let qualified = format!("attrs.{key}");
env = env.extend(std::sync::Arc::from(qualified.as_str()), lit.clone());
if !fields.contains_key(key) {
env = env.extend(std::sync::Arc::from(key.as_str()), lit);
}
}
}
env
}
#[must_use]
pub fn value_to_expr_literal(val: &Value) -> panproto_expr::Literal {
match val {
Value::Bool(b) => panproto_expr::Literal::Bool(*b),
Value::Int(i) => panproto_expr::Literal::Int(*i),
Value::Float(f) => panproto_expr::Literal::Float(*f),
Value::Str(s) => panproto_expr::Literal::Str(s.clone()),
Value::Bytes(b) => panproto_expr::Literal::Bytes(b.clone()),
Value::List(items) => {
panproto_expr::Literal::List(items.iter().map(value_to_expr_literal).collect())
}
Value::Unknown(map) => {
let mut fields: Vec<(std::sync::Arc<str>, panproto_expr::Literal)> = map
.iter()
.map(|(k, v)| (std::sync::Arc::from(k.as_str()), value_to_expr_literal(v)))
.collect();
fields.sort_by(|(a, _), (b, _)| a.cmp(b));
panproto_expr::Literal::Record(fields)
}
_ => panproto_expr::Literal::Null,
}
}
#[must_use]
pub fn expr_literal_to_value(lit: &panproto_expr::Literal) -> Value {
match lit {
panproto_expr::Literal::Bool(b) => Value::Bool(*b),
panproto_expr::Literal::Int(i) => Value::Int(*i),
panproto_expr::Literal::Float(f) => {
#[allow(clippy::cast_precision_loss)]
let fits = f.fract() == 0.0 && *f >= i64::MIN as f64 && *f <= i64::MAX as f64;
if fits {
#[allow(clippy::cast_possible_truncation)]
let i = *f as i64;
Value::Int(i)
} else {
Value::Float(*f)
}
}
panproto_expr::Literal::Str(s) => Value::Str(s.clone()),
panproto_expr::Literal::Bytes(b) => Value::Bytes(b.clone()),
panproto_expr::Literal::List(items) => {
Value::List(items.iter().map(expr_literal_to_value).collect())
}
panproto_expr::Literal::Record(fields) => Value::Unknown(
fields
.iter()
.map(|(k, v)| (k.to_string(), expr_literal_to_value(v)))
.collect(),
),
panproto_expr::Literal::Null | panproto_expr::Literal::Closure { .. } => Value::Null,
}
}
fn prepare_root_node(
root_node: &Node,
migration: &CompiledMigration,
instance: &WInstance,
) -> Result<Node, RestrictError> {
let mut node = root_node.clone();
if let Some(remapped) = migration.vertex_remap.get(&root_node.anchor) {
node.anchor.clone_from(remapped);
}
if let Some(pred) = migration.conditional_survival.get(&root_node.anchor) {
let env = build_env_from_extra_fields(&root_node.extra_fields);
let config = panproto_expr::EvalConfig::default();
if matches!(
panproto_expr::eval(pred, &env, &config),
Ok(panproto_expr::Literal::Bool(false))
) {
return Err(RestrictError::RootPruned);
}
}
let transforms = migration.value_transforms(&root_node.anchor);
if !transforms.is_empty() {
let scalars = collect_scalar_child_values(instance, root_node.id);
apply_field_transforms(&mut node, &transforms, &scalars)?;
}
Ok(node)
}
pub fn wtype_extend(
instance: &WInstance,
tgt_schema: &Schema,
migration: &CompiledMigration,
) -> Result<WInstance, RestrictError> {
let (extended, _dropped) = wtype_extend_inner(instance, tgt_schema, migration, false)?;
Ok(extended)
}
pub fn wtype_extend_partial(
instance: &WInstance,
tgt_schema: &Schema,
migration: &CompiledMigration,
) -> Result<(WInstance, Vec<u32>), RestrictError> {
wtype_extend_inner(instance, tgt_schema, migration, true)
}
fn wtype_extend_inner(
instance: &WInstance,
tgt_schema: &Schema,
migration: &CompiledMigration,
partial: bool,
) -> Result<(WInstance, Vec<u32>), RestrictError> {
let mut dropped_nodes: Vec<u32> = Vec::new();
let root_node = instance
.nodes
.get(&instance.root)
.ok_or(RestrictError::RootPruned)?;
let root_anchor = &root_node.anchor;
if !migration.surviving_verts.contains(root_anchor)
&& !migration.vertex_remap.contains_key(root_anchor)
{
return Err(RestrictError::RootPruned);
}
let mut new_nodes: HashMap<u32, Node> = HashMap::with_capacity(instance.nodes.len());
for (&id, node) in &instance.nodes {
let mut new_node = node.clone();
if let Some(remapped) = migration.vertex_remap.get(&node.anchor) {
new_node.anchor.clone_from(remapped);
} else if !migration.surviving_verts.contains(&node.anchor) {
if partial {
dropped_nodes.push(id);
continue;
}
return Err(RestrictError::UnmappedAnchor {
anchor: node.anchor.clone(),
node_id: id,
});
}
let transforms = migration.value_transforms(&node.anchor);
if !transforms.is_empty() {
let scalars = collect_scalar_child_values(instance, id);
apply_field_transforms(&mut new_node, &transforms, &scalars)?;
}
new_nodes.insert(id, new_node);
}
let mut new_arcs: Vec<(u32, u32, Edge)> = Vec::with_capacity(instance.arcs.len());
for &(parent, child, ref edge) in &instance.arcs {
if !new_nodes.contains_key(&parent) || !new_nodes.contains_key(&child) {
continue;
}
if let Some(new_edge) = migration.edge_remap.get(edge) {
new_arcs.push((parent, child, new_edge.clone()));
} else if migration.surviving_edges.contains(edge) {
let parent_anchor = &new_nodes[&parent].anchor;
let child_anchor = &new_nodes[&child].anchor;
if edge.src == *parent_anchor && edge.tgt == *child_anchor {
new_arcs.push((parent, child, edge.clone()));
} else {
let resolved =
resolve_edge(tgt_schema, &migration.resolver, parent_anchor, child_anchor)?;
new_arcs.push((parent, child, resolved));
}
} else {
let parent_anchor = &new_nodes[&parent].anchor;
let child_anchor = &new_nodes[&child].anchor;
let resolved =
resolve_edge(tgt_schema, &migration.resolver, parent_anchor, child_anchor)?;
new_arcs.push((parent, child, resolved));
}
}
let surviving_ids: FxHashSet<u32> = new_nodes.keys().copied().collect();
let empty_ancestors = FxHashMap::default();
let new_fans = reconstruct_fans(
instance,
&surviving_ids,
&empty_ancestors,
migration,
tgt_schema,
)?;
let new_schema_root = migration
.vertex_remap
.get(&instance.schema_root)
.cloned()
.unwrap_or_else(|| instance.schema_root.clone());
Ok((
WInstance::new(
new_nodes,
new_arcs,
new_fans,
instance.root,
new_schema_root,
),
dropped_nodes,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::{FieldPresence, Value};
fn three_node_instance() -> WInstance {
let mut nodes = HashMap::new();
nodes.insert(0, Node::new(0, panproto_gat::Name::from("post:body")));
nodes.insert(
1,
Node::new(1, "post:body.text")
.with_value(FieldPresence::Present(Value::Str("hello".into()))),
);
nodes.insert(
2,
Node::new(2, "post:body.createdAt")
.with_value(FieldPresence::Present(Value::Str("2024-01-01".into()))),
);
let arcs = vec![
(
0,
1,
Edge {
src: "post:body".into(),
tgt: "post:body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
},
),
(
0,
2,
Edge {
src: "post:body".into(),
tgt: "post:body.createdAt".into(),
kind: "prop".into(),
name: Some("createdAt".into()),
},
),
];
WInstance::new(
nodes,
arcs,
vec![],
0,
panproto_gat::Name::from("post:body"),
)
}
#[test]
fn anchor_surviving_keeps_matching_nodes() {
let inst = three_node_instance();
let surviving_verts: HashSet<Name> = ["post:body", "post:body.text"]
.iter()
.map(|&s| Name::from(s))
.collect();
let result = anchor_surviving(&inst, &surviving_verts);
assert_eq!(result.len(), 2);
assert!(result.contains(&0));
assert!(result.contains(&1));
assert!(!result.contains(&2));
}
#[test]
fn ancestor_contraction_direct_parent() {
let inst = three_node_instance();
let surviving: HashSet<u32> = [0, 1, 2].iter().copied().collect();
let ancestors = ancestor_contraction(&inst, &surviving);
assert_eq!(ancestors.get(&1), Some(&0));
assert_eq!(ancestors.get(&2), Some(&0));
}
#[test]
fn resolve_edge_unique() {
use smallvec::smallvec;
let mut between = HashMap::new();
let edge = Edge {
src: "a".into(),
tgt: "b".into(),
kind: "prop".into(),
name: Some("x".into()),
};
between.insert((Name::from("a"), Name::from("b")), smallvec![edge.clone()]);
let schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between,
};
let resolver = HashMap::new();
let result = resolve_edge(&schema, &resolver, "a", "b");
assert!(result.is_ok());
assert_eq!(result.ok(), Some(edge));
}
#[test]
fn resolve_edge_uses_resolver() {
let schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between: HashMap::new(),
};
let resolved_edge = Edge {
src: "a".into(),
tgt: "b".into(),
kind: "prop".into(),
name: Some("resolved".into()),
};
let mut resolver = HashMap::new();
resolver.insert((Name::from("a"), Name::from("b")), resolved_edge.clone());
let result = resolve_edge(&schema, &resolver, "a", "b");
assert!(result.is_ok());
assert_eq!(result.ok(), Some(resolved_edge));
}
#[allow(clippy::unwrap_used)]
fn make_test_schema(vertices: &[&str], edges: &[Edge]) -> Schema {
use smallvec::smallvec;
let mut between = HashMap::new();
for edge in edges {
between
.entry((Name::from(&*edge.src), Name::from(&*edge.tgt)))
.or_insert_with(|| smallvec![])
.push(edge.clone());
}
Schema {
protocol: "test".into(),
vertices: vertices
.iter()
.map(|&v| {
(
Name::from(v),
panproto_schema::Vertex {
id: Name::from(v),
kind: Name::from("object"),
nsid: None,
},
)
})
.collect(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between,
}
}
#[test]
#[allow(clippy::unwrap_used)]
fn extend_identity_migration() {
let inst = three_node_instance();
let edge_text = Edge {
src: "post:body".into(),
tgt: "post:body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
};
let edge_time = Edge {
src: "post:body".into(),
tgt: "post:body.createdAt".into(),
kind: "prop".into(),
name: Some("createdAt".into()),
};
let surviving_edges = HashSet::from([edge_text.clone(), edge_time.clone()]);
let schema = make_test_schema(
&["post:body", "post:body.text", "post:body.createdAt"],
&[edge_text, edge_time],
);
let migration = CompiledMigration {
surviving_verts: HashSet::from([
Name::from("post:body"),
Name::from("post:body.text"),
Name::from("post:body.createdAt"),
]),
surviving_edges,
vertex_remap: HashMap::new(),
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let result = wtype_extend(&inst, &schema, &migration).unwrap();
assert_eq!(result.node_count(), 3);
assert_eq!(result.arc_count(), 2);
assert_eq!(result.schema_root, Name::from("post:body"));
}
#[test]
#[allow(clippy::unwrap_used)]
fn extend_with_vertex_remap() {
let inst = three_node_instance();
let tgt_edge_text = Edge {
src: "article:body".into(),
tgt: "article:body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
};
let tgt_edge_time = Edge {
src: "article:body".into(),
tgt: "article:body.createdAt".into(),
kind: "prop".into(),
name: Some("createdAt".into()),
};
let tgt_schema = make_test_schema(
&[
"article:body",
"article:body.text",
"article:body.createdAt",
],
&[tgt_edge_text, tgt_edge_time],
);
let mut vertex_remap = HashMap::new();
vertex_remap.insert(Name::from("post:body"), Name::from("article:body"));
vertex_remap.insert(
Name::from("post:body.text"),
Name::from("article:body.text"),
);
vertex_remap.insert(
Name::from("post:body.createdAt"),
Name::from("article:body.createdAt"),
);
let migration = CompiledMigration {
surviving_verts: HashSet::from([
Name::from("article:body"),
Name::from("article:body.text"),
Name::from("article:body.createdAt"),
]),
surviving_edges: HashSet::new(),
vertex_remap,
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let result = wtype_extend(&inst, &tgt_schema, &migration).unwrap();
assert_eq!(result.node_count(), 3);
assert_eq!(result.arc_count(), 2);
assert_eq!(result.schema_root, Name::from("article:body"));
assert_eq!(result.nodes[&0].anchor, Name::from("article:body"));
assert_eq!(result.nodes[&1].anchor, Name::from("article:body.text"));
}
#[test]
#[allow(clippy::unwrap_used)]
fn extend_with_edge_remap() {
let inst = three_node_instance();
let src_edge_text = Edge {
src: "post:body".into(),
tgt: "post:body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
};
let new_edge_text = Edge {
src: "post:body".into(),
tgt: "post:body.text".into(),
kind: "prop".into(),
name: Some("content".into()),
};
let edge_time = Edge {
src: "post:body".into(),
tgt: "post:body.createdAt".into(),
kind: "prop".into(),
name: Some("createdAt".into()),
};
let surviving_edges = HashSet::from([edge_time.clone()]);
let tgt_schema = make_test_schema(
&["post:body", "post:body.text", "post:body.createdAt"],
&[new_edge_text.clone(), edge_time],
);
let mut edge_remap = HashMap::new();
edge_remap.insert(src_edge_text, new_edge_text);
let migration = CompiledMigration {
surviving_verts: HashSet::from([
Name::from("post:body"),
Name::from("post:body.text"),
Name::from("post:body.createdAt"),
]),
surviving_edges,
vertex_remap: HashMap::new(),
edge_remap,
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let result = wtype_extend(&inst, &tgt_schema, &migration).unwrap();
assert_eq!(result.arc_count(), 2);
let text_arc = result.arcs.iter().find(|a| a.1 == 1).unwrap();
assert_eq!(text_arc.2.name.as_deref(), Some("content"));
}
#[test]
#[allow(clippy::unwrap_used)]
fn extend_preserves_structure() {
let inst = three_node_instance();
let edge_text = Edge {
src: "post:body".into(),
tgt: "post:body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
};
let edge_time = Edge {
src: "post:body".into(),
tgt: "post:body.createdAt".into(),
kind: "prop".into(),
name: Some("createdAt".into()),
};
let surviving_edges = HashSet::from([edge_text.clone(), edge_time.clone()]);
let schema = make_test_schema(
&["post:body", "post:body.text", "post:body.createdAt"],
&[edge_text, edge_time],
);
let migration = CompiledMigration {
surviving_verts: HashSet::from([
Name::from("post:body"),
Name::from("post:body.text"),
Name::from("post:body.createdAt"),
]),
surviving_edges,
vertex_remap: HashMap::new(),
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let result = wtype_extend(&inst, &schema, &migration).unwrap();
assert_eq!(result.parent(1), Some(0));
assert_eq!(result.parent(2), Some(0));
assert!(result.children(0).contains(&1));
assert!(result.children(0).contains(&2));
assert!(result.nodes[&1].has_value());
assert!(result.nodes[&2].has_value());
}
#[test]
#[allow(clippy::unwrap_used)]
fn extend_errors_on_unmapped_anchor() {
let inst = three_node_instance();
let edge_text = Edge {
src: "post:body".into(),
tgt: "post:body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
};
let schema = make_test_schema(
&["post:body", "post:body.text"],
std::slice::from_ref(&edge_text),
);
let migration = CompiledMigration {
surviving_verts: HashSet::from([Name::from("post:body"), Name::from("post:body.text")]),
surviving_edges: HashSet::from([edge_text]),
vertex_remap: HashMap::new(),
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let err = wtype_extend(&inst, &schema, &migration).unwrap_err();
assert!(
matches!(err, RestrictError::UnmappedAnchor { node_id: 2, .. }),
"expected UnmappedAnchor for node 2, got {err:?}"
);
let (extended, dropped) = wtype_extend_partial(&inst, &schema, &migration).unwrap();
assert_eq!(dropped, vec![2]);
assert_eq!(extended.node_count(), 2);
assert!(!extended.nodes.contains_key(&2));
}
#[test]
#[allow(clippy::expect_used, clippy::too_many_lines)]
fn restrict_renamed_vertex_preserves_value() {
use smallvec::smallvec;
let mut nodes = HashMap::new();
nodes.insert(0, Node::new(0, Name::from("post:body")));
nodes.insert(
1,
Node::new(1, "post:text")
.with_value(FieldPresence::Present(Value::Str("hello".into()))),
);
nodes.insert(
2,
Node::new(2, "post:title")
.with_value(FieldPresence::Present(Value::Str("world".into()))),
);
let arcs = vec![
(
0,
1,
Edge {
src: "post:body".into(),
tgt: "post:text".into(),
kind: "prop".into(),
name: Some("text".into()),
},
),
(
0,
2,
Edge {
src: "post:body".into(),
tgt: "post:title".into(),
kind: "prop".into(),
name: Some("title".into()),
},
),
];
let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("post:body"));
let tgt_content_edge = Edge {
src: "post:body".into(),
tgt: "post:content".into(),
kind: "prop".into(),
name: Some("content".into()),
};
let tgt_title_edge = Edge {
src: "post:body".into(),
tgt: "post:title".into(),
kind: "prop".into(),
name: Some("title".into()),
};
let mut tgt_between = HashMap::new();
tgt_between.insert(
(Name::from("post:body"), Name::from("post:content")),
smallvec![tgt_content_edge],
);
tgt_between.insert(
(Name::from("post:body"), Name::from("post:title")),
smallvec![tgt_title_edge],
);
let tgt_schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between: tgt_between,
};
let mut surviving_verts = HashSet::new();
surviving_verts.insert(Name::from("post:body"));
surviving_verts.insert(Name::from("post:content")); surviving_verts.insert(Name::from("post:title"));
let mut vertex_remap = HashMap::new();
vertex_remap.insert(Name::from("post:text"), Name::from("post:content"));
let migration = CompiledMigration {
surviving_verts,
surviving_edges: HashSet::new(),
vertex_remap,
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let src_schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between: HashMap::new(),
};
let result = wtype_restrict(&inst, &src_schema, &tgt_schema, &migration)
.expect("restrict should succeed");
assert_eq!(result.nodes.len(), 3, "all three nodes should survive");
let renamed_node = result.nodes.get(&1).expect("node 1 should survive");
assert_eq!(renamed_node.anchor.as_ref(), "post:content");
assert!(renamed_node.has_value(), "renamed node must keep its value");
assert!(
matches!(
&renamed_node.value,
Some(FieldPresence::Present(Value::Str(s))) if s.as_str() == "hello"
),
"expected Some(Present(Str(\"hello\"))), got {:?}",
renamed_node.value,
);
}
#[test]
#[allow(clippy::expect_used)]
fn path_transform_renames_nested_field() {
let mut node = Node::new(0, "v");
let mut inner_map = HashMap::new();
inner_map.insert("old_attr".to_string(), Value::Str("val".into()));
node.extra_fields
.insert("attrs".to_string(), Value::Unknown(inner_map));
let transform = FieldTransform::PathTransform {
path: vec!["attrs".to_string()],
inner: Box::new(FieldTransform::RenameField {
old_key: "old_attr".to_string(),
new_key: "new_attr".to_string(),
}),
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
match node.extra_fields.get("attrs") {
Some(Value::Unknown(map)) => {
assert!(!map.contains_key("old_attr"));
assert_eq!(map.get("new_attr"), Some(&Value::Str("val".into())));
}
other => panic!("expected Unknown map, got {other:?}"),
}
}
#[test]
#[allow(clippy::expect_used)]
fn path_transform_empty_path_is_identity() {
let mut node = Node::new(0, "v");
node.extra_fields
.insert("color".to_string(), Value::Str("red".into()));
let transform = FieldTransform::PathTransform {
path: vec![],
inner: Box::new(FieldTransform::RenameField {
old_key: "color".to_string(),
new_key: "colour".to_string(),
}),
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
assert!(!node.extra_fields.contains_key("color"));
assert_eq!(
node.extra_fields.get("colour"),
Some(&Value::Str("red".into()))
);
}
#[test]
#[allow(clippy::expect_used)]
fn map_references_renames_string_field() {
let mut node = Node::new(0, "v");
node.extra_fields
.insert("parent".to_string(), Value::Str("old_name".into()));
let mut rename_map = HashMap::new();
rename_map.insert("old_name".to_string(), Some("new_name".to_string()));
let transform = FieldTransform::MapReferences {
field: "parent".to_string(),
rename_map,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("parent"),
Some(&Value::Str("new_name".into()))
);
}
#[test]
#[allow(clippy::expect_used)]
fn map_references_filters_list() {
let mut node = Node::new(0, "v");
node.extra_fields.insert(
"parents".to_string(),
Value::List(vec![
Value::Str("alpha".into()),
Value::Str("beta".into()),
Value::Str("gamma".into()),
]),
);
let mut rename_map = HashMap::new();
rename_map.insert("alpha".to_string(), Some("alpha_v2".to_string()));
rename_map.insert("beta".to_string(), None);
let transform = FieldTransform::MapReferences {
field: "parents".to_string(),
rename_map,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
match node.extra_fields.get("parents") {
Some(Value::List(items)) => {
assert_eq!(items.len(), 2);
assert_eq!(items[0], Value::Str("alpha_v2".into()));
assert_eq!(items[1], Value::Str("gamma".into()));
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
#[allow(clippy::expect_used)]
fn map_references_drops_removed_entries() {
let mut node = Node::new(0, "v");
node.extra_fields.insert(
"refs".to_string(),
Value::List(vec![
Value::Str("gone".into()),
Value::Str("also_gone".into()),
]),
);
let mut rename_map = HashMap::new();
rename_map.insert("gone".to_string(), None);
rename_map.insert("also_gone".to_string(), None);
let transform = FieldTransform::MapReferences {
field: "refs".to_string(),
rename_map,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
match node.extra_fields.get("refs") {
Some(Value::List(items)) => {
assert!(items.is_empty(), "expected empty list, got {items:?}");
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn value_to_expr_literal_preserves_string_list() {
let val = Value::List(vec![
Value::Str("a".into()),
Value::Str("b".into()),
Value::Str("c".into()),
]);
match value_to_expr_literal(&val) {
panproto_expr::Literal::List(items) => assert_eq!(
items,
vec![
panproto_expr::Literal::Str("a".into()),
panproto_expr::Literal::Str("b".into()),
panproto_expr::Literal::Str("c".into()),
]
),
other => panic!("expected Literal::List, got {other:?}"),
}
}
#[test]
fn value_to_expr_literal_keeps_non_string_list_elements() {
let val = Value::List(vec![
Value::Str("keep".into()),
Value::Int(42),
Value::Bool(true),
Value::Null,
]);
match value_to_expr_literal(&val) {
panproto_expr::Literal::List(items) => assert_eq!(
items,
vec![
panproto_expr::Literal::Str("keep".into()),
panproto_expr::Literal::Int(42),
panproto_expr::Literal::Bool(true),
panproto_expr::Literal::Null,
],
"non-string list elements must survive the conversion"
),
other => panic!("expected Literal::List, got {other:?}"),
}
}
#[test]
fn value_to_expr_literal_empty_list_is_empty_list() {
match value_to_expr_literal(&Value::List(Vec::new())) {
panproto_expr::Literal::List(items) => assert!(items.is_empty()),
other => panic!("expected Literal::List, got {other:?}"),
}
}
#[test]
fn value_to_expr_literal_nests_lists_and_records() {
let val = Value::List(vec![Value::Unknown(HashMap::from([
("a".to_string(), Value::Int(1)),
("b".to_string(), Value::Int(10)),
]))]);
match value_to_expr_literal(&val) {
panproto_expr::Literal::List(items) => match &items[..] {
[panproto_expr::Literal::Record(fields)] => {
assert_eq!(fields.len(), 2);
assert_eq!(&*fields[0].0, "a");
assert_eq!(fields[0].1, panproto_expr::Literal::Int(1));
assert_eq!(&*fields[1].0, "b");
assert_eq!(fields[1].1, panproto_expr::Literal::Int(10));
}
other => panic!("expected one Record element, got {other:?}"),
},
other => panic!("expected Literal::List, got {other:?}"),
}
}
#[test]
fn value_to_expr_literal_record_fields_are_sorted() {
let val = Value::Unknown(HashMap::from([
("zulu".to_string(), Value::Int(3)),
("alpha".to_string(), Value::Int(1)),
("mike".to_string(), Value::Int(2)),
]));
for _ in 0..16 {
match value_to_expr_literal(&val) {
panproto_expr::Literal::Record(fields) => {
let keys: Vec<&str> = fields.iter().map(|(k, _)| &**k).collect();
assert_eq!(keys, vec!["alpha", "mike", "zulu"]);
}
other => panic!("expected Literal::Record, got {other:?}"),
}
}
}
#[test]
fn value_to_expr_literal_non_collection_variants_pass_through() {
assert!(matches!(
value_to_expr_literal(&Value::Bool(true)),
panproto_expr::Literal::Bool(true)
));
assert!(matches!(
value_to_expr_literal(&Value::Int(7)),
panproto_expr::Literal::Int(7)
));
assert!(matches!(
value_to_expr_literal(&Value::Null),
panproto_expr::Literal::Null
));
assert_eq!(
value_to_expr_literal(&Value::Bytes(vec![1, 2, 3])),
panproto_expr::Literal::Bytes(vec![1, 2, 3])
);
assert!(matches!(
value_to_expr_literal(&Value::Unknown(HashMap::new())),
panproto_expr::Literal::Record(ref f) if f.is_empty()
));
assert!(matches!(
value_to_expr_literal(&Value::Token("t".into())),
panproto_expr::Literal::Null
));
}
#[test]
fn expr_literal_to_value_preserves_lists_and_records() {
let lit = panproto_expr::Literal::List(vec![panproto_expr::Literal::Record(vec![
(std::sync::Arc::from("x"), panproto_expr::Literal::Int(1)),
(
std::sync::Arc::from("y"),
panproto_expr::Literal::Str("s".into()),
),
])]);
match expr_literal_to_value(&lit) {
Value::List(items) => match &items[..] {
[Value::Unknown(map)] => {
assert_eq!(map.get("x"), Some(&Value::Int(1)));
assert_eq!(map.get("y"), Some(&Value::Str("s".into())));
}
other => panic!("expected one Unknown element, got {other:?}"),
},
other => panic!("expected Value::List, got {other:?}"),
}
}
#[test]
fn value_literal_round_trip_is_identity_on_containers() {
let val = Value::Unknown(HashMap::from([
(
"nums".to_string(),
Value::List(vec![Value::Int(1), Value::Int(2)]),
),
(
"nested".to_string(),
Value::Unknown(HashMap::from([("a".to_string(), Value::Str("z".into()))])),
),
("flag".to_string(), Value::Bool(false)),
]));
assert_eq!(expr_literal_to_value(&value_to_expr_literal(&val)), val);
}
fn node_with_container_fields() -> Node {
let mut node = Node::new(0, "rec");
node.extra_fields.insert(
"nums".to_string(),
Value::List(vec![Value::Int(1), Value::Int(2), Value::Int(3)]),
);
node.extra_fields.insert(
"objs".to_string(),
Value::List(vec![
Value::Unknown(HashMap::from([
("a".to_string(), Value::Int(1)),
("b".to_string(), Value::Int(10)),
])),
Value::Unknown(HashMap::from([
("a".to_string(), Value::Int(2)),
("b".to_string(), Value::Int(20)),
])),
]),
);
node.extra_fields.insert(
"nested".to_string(),
Value::Unknown(HashMap::from([
("a".to_string(), Value::Int(7)),
("b".to_string(), Value::Int(70)),
])),
);
node
}
fn increment_lambda() -> panproto_expr::Expr {
panproto_expr::Expr::Lam(
std::sync::Arc::from("x"),
Box::new(panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Add,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("x")),
panproto_expr::Expr::Lit(panproto_expr::Literal::Int(1)),
],
)),
)
}
#[test]
#[allow(clippy::expect_used)]
fn apply_expr_maps_over_an_integer_list() {
let mut node = node_with_container_fields();
let transform = FieldTransform::ApplyExpr {
key: "nums".to_string(),
expr: panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Map,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("nums")),
increment_lambda(),
],
),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("map over a list field should evaluate");
assert_eq!(
node.extra_fields.get("nums"),
Some(&Value::List(vec![
Value::Int(2),
Value::Int(3),
Value::Int(4)
])),
"map over an integer list must produce the incremented list, not leave it untouched"
);
}
#[test]
#[allow(clippy::expect_used)]
fn apply_expr_maps_a_projection_over_a_record_list() {
let mut node = node_with_container_fields();
let project_a = panproto_expr::Expr::Lam(
std::sync::Arc::from("o"),
Box::new(panproto_expr::Expr::Field(
Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
std::sync::Arc::from("a"),
)),
);
let transform = FieldTransform::ApplyExpr {
key: "objs".to_string(),
expr: panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Map,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("objs")),
project_a,
],
),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("map over a record list should evaluate");
assert_eq!(
node.extra_fields.get("objs"),
Some(&Value::List(vec![Value::Int(1), Value::Int(2)])),
"field projection over a list of records must reach each record's fields"
);
}
#[test]
#[allow(clippy::expect_used)]
fn compute_field_reads_through_a_nested_record() {
let mut node = node_with_container_fields();
let transform = FieldTransform::ComputeField {
target_key: "out".to_string(),
expr: panproto_expr::Expr::Field(
Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("nested"))),
std::sync::Arc::from("a"),
),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("field access into a nested record should evaluate");
assert_eq!(
node.extra_fields.get("out"),
Some(&Value::Int(7)),
"field access into a nested object must resolve, not emit nothing"
);
}
#[test]
#[allow(clippy::expect_used)]
fn compute_field_folds_over_a_list() {
let mut node = node_with_container_fields();
let add = panproto_expr::Expr::Lam(
std::sync::Arc::from("x"),
Box::new(panproto_expr::Expr::Lam(
std::sync::Arc::from("y"),
Box::new(panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Add,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("x")),
panproto_expr::Expr::Var(std::sync::Arc::from("y")),
],
)),
)),
);
let transform = FieldTransform::ComputeField {
target_key: "out".to_string(),
expr: panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Fold,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("nums")),
panproto_expr::Expr::Lit(panproto_expr::Literal::Int(0)),
add,
],
),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("fold over a list field should evaluate");
assert_eq!(
node.extra_fields.get("out"),
Some(&Value::Int(6)),
"fold over an integer list must sum it"
);
}
#[test]
#[allow(clippy::expect_used)]
fn compute_field_builds_a_nested_record_list() {
let mut node = node_with_container_fields();
let regroup = panproto_expr::Expr::Lam(
std::sync::Arc::from("o"),
Box::new(panproto_expr::Expr::Record(vec![
(
std::sync::Arc::from("outer"),
panproto_expr::Expr::Field(
Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
std::sync::Arc::from("a"),
),
),
(
std::sync::Arc::from("inner"),
panproto_expr::Expr::Record(vec![(
std::sync::Arc::from("deep"),
panproto_expr::Expr::Field(
Box::new(panproto_expr::Expr::Var(std::sync::Arc::from("o"))),
std::sync::Arc::from("b"),
),
)]),
),
])),
);
let transform = FieldTransform::ComputeField {
target_key: "regrouped".to_string(),
expr: panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Map,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("objs")),
regroup,
],
),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("building a nested record list should evaluate");
let expected = Value::List(vec![
Value::Unknown(HashMap::from([
("outer".to_string(), Value::Int(1)),
(
"inner".to_string(),
Value::Unknown(HashMap::from([("deep".to_string(), Value::Int(10))])),
),
])),
Value::Unknown(HashMap::from([
("outer".to_string(), Value::Int(2)),
(
"inner".to_string(),
Value::Unknown(HashMap::from([("deep".to_string(), Value::Int(20))])),
),
])),
]);
assert_eq!(node.extra_fields.get("regrouped"), Some(&expected));
}
#[test]
#[allow(clippy::expect_used)]
fn contains_tests_membership_on_a_list_field() {
let mut node = Node::new(0, "rec");
node.extra_fields.insert(
"tags".to_string(),
Value::List(vec![Value::Str("alpha".into()), Value::Str("beta".into())]),
);
let case = FieldTransform::Case {
branches: vec![CaseBranch {
predicate: panproto_expr::Expr::builtin(
panproto_expr::BuiltinOp::Contains,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("tags")),
panproto_expr::Expr::Lit(panproto_expr::Literal::Str("beta".into())),
],
),
transforms: vec![FieldTransform::AddField {
key: "matched".into(),
value: Value::Bool(true),
}],
}],
};
apply_field_transforms(&mut node, &[case], &HashMap::new())
.expect("membership predicate should evaluate");
assert_eq!(
node.extra_fields.get("matched"),
Some(&Value::Bool(true)),
"Contains must test element membership on a list-valued field"
);
}
#[test]
#[allow(clippy::expect_used)]
fn contains_on_a_list_does_not_match_a_substring_of_an_element() {
let mut node = Node::new(0, "rec");
node.extra_fields.insert(
"tags".to_string(),
Value::List(vec![Value::Str("alpha".into())]),
);
let case = FieldTransform::Case {
branches: vec![CaseBranch {
predicate: panproto_expr::Expr::builtin(
panproto_expr::BuiltinOp::Contains,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("tags")),
panproto_expr::Expr::Lit(panproto_expr::Literal::Str("lph".into())),
],
),
transforms: vec![FieldTransform::AddField {
key: "matched".into(),
value: Value::Bool(true),
}],
}],
};
apply_field_transforms(&mut node, &[case], &HashMap::new())
.expect("membership predicate should evaluate");
assert!(
!node.extra_fields.contains_key("matched"),
"list membership must be exact-element, not substring"
);
}
#[test]
#[allow(clippy::expect_used)]
fn failed_transform_reports_instead_of_silently_skipping() {
let mut node = Node::new(0, "rec");
node.extra_fields.insert("n".to_string(), Value::Int(1));
let transform = FieldTransform::ComputeField {
target_key: "out".to_string(),
expr: panproto_expr::Expr::Var(std::sync::Arc::from("missing")),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
let err = apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect_err("an unevaluable transform must report");
match err {
RestrictError::FieldTransformFailed { key, .. } => assert_eq!(key, "out"),
other => panic!("expected FieldTransformFailed, got {other:?}"),
}
assert!(
!node.extra_fields.contains_key("out"),
"a failed transform must not write a partial result"
);
}
#[test]
#[allow(clippy::expect_used)]
fn failed_nested_transform_preserves_the_nested_map() {
let mut node = Node::new(0, "rec");
node.extra_fields.insert(
"outer".to_string(),
Value::Unknown(HashMap::from([("kept".to_string(), Value::Int(5))])),
);
let transform = FieldTransform::PathTransform {
path: vec!["outer".to_string()],
inner: Box::new(FieldTransform::ComputeField {
target_key: "out".to_string(),
expr: panproto_expr::Expr::Var(std::sync::Arc::from("missing")),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
}),
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect_err("an unevaluable nested transform must report");
assert_eq!(
node.extra_fields.get("outer"),
Some(&Value::Unknown(HashMap::from([(
"kept".to_string(),
Value::Int(5)
)]))),
"the nested map must survive a failed transform"
);
}
#[test]
#[allow(clippy::expect_used)]
fn map_references_preserves_non_string_elements() {
let mut node = Node::new(0, "v");
node.extra_fields.insert(
"mixed".to_string(),
Value::List(vec![
Value::Str("renameme".into()),
Value::Int(42),
Value::Bool(true),
Value::Str("dropme".into()),
]),
);
let mut rename_map = HashMap::new();
rename_map.insert("renameme".to_string(), Some("renamed".to_string()));
rename_map.insert("dropme".to_string(), None);
let transform = FieldTransform::MapReferences {
field: "mixed".to_string(),
rename_map,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
match node.extra_fields.get("mixed") {
Some(Value::List(items)) => {
assert_eq!(items.len(), 3);
assert_eq!(items[0], Value::Str("renamed".into()));
assert_eq!(items[1], Value::Int(42));
assert_eq!(items[2], Value::Bool(true));
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
#[allow(clippy::expect_used)]
fn conditional_survival_drops_non_matching_node() {
use smallvec::smallvec;
let mut nodes = HashMap::new();
nodes.insert(0, Node::new(0, Name::from("root")));
nodes.insert(
1,
Node::new(1, "item").with_extra_field("level", Value::Int(2)),
);
nodes.insert(
2,
Node::new(2, "item").with_extra_field("level", Value::Int(1)),
);
let edge = Edge {
src: "root".into(),
tgt: "item".into(),
kind: "prop".into(),
name: Some("child".into()),
};
let arcs = vec![(0, 1, edge.clone()), (0, 2, edge.clone())];
let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("root"));
let mut between = HashMap::new();
between.insert(
(Name::from("root"), Name::from("item")),
smallvec![edge.clone()],
);
let tgt_schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between,
};
let src_schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between: HashMap::new(),
};
let predicate = panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Eq,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("level")),
panproto_expr::Expr::Lit(panproto_expr::Literal::Int(2)),
],
);
let mut migration = CompiledMigration {
surviving_verts: HashSet::from([Name::from("root"), Name::from("item")]),
surviving_edges: HashSet::from([edge]),
vertex_remap: HashMap::new(),
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
migration.add_conditional_survival("item", predicate);
let result =
wtype_restrict(&inst, &src_schema, &tgt_schema, &migration).expect("restrict ok");
assert_eq!(result.node_count(), 2);
assert!(result.nodes.contains_key(&0));
assert!(result.nodes.contains_key(&1));
assert!(!result.nodes.contains_key(&2));
}
#[test]
#[allow(clippy::expect_used)]
fn conditional_survival_no_predicate_survives() {
use smallvec::smallvec;
let mut nodes = HashMap::new();
nodes.insert(0, Node::new(0, Name::from("root")));
nodes.insert(
1,
Node::new(1, "item").with_extra_field("level", Value::Int(1)),
);
let edge = Edge {
src: "root".into(),
tgt: "item".into(),
kind: "prop".into(),
name: Some("child".into()),
};
let arcs = vec![(0, 1, edge.clone())];
let inst = WInstance::new(nodes, arcs, vec![], 0, Name::from("root"));
let mut between = HashMap::new();
between.insert(
(Name::from("root"), Name::from("item")),
smallvec![edge.clone()],
);
let tgt_schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between,
};
let src_schema = Schema {
protocol: "test".into(),
vertices: HashMap::new(),
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between: HashMap::new(),
};
let migration = CompiledMigration {
surviving_verts: HashSet::from([Name::from("root"), Name::from("item")]),
surviving_edges: HashSet::from([edge]),
vertex_remap: HashMap::new(),
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let result =
wtype_restrict(&inst, &src_schema, &tgt_schema, &migration).expect("restrict ok");
assert_eq!(result.node_count(), 2);
assert!(result.nodes.contains_key(&1));
}
#[test]
#[allow(clippy::expect_used)]
fn computed_field_template_name() {
let mut node = Node::new(0, "heading");
node.extra_fields.insert("level".to_string(), Value::Int(2));
let expr = panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Concat,
vec![
panproto_expr::Expr::Lit(panproto_expr::Literal::Str("h".to_string())),
panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::IntToStr,
vec![panproto_expr::Expr::Var(std::sync::Arc::from("level"))],
),
],
);
let transform = FieldTransform::ComputeField {
target_key: "name".to_string(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Opaque,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("name"),
Some(&Value::Str("h2".into()))
);
}
#[test]
#[allow(clippy::expect_used)]
fn computed_field_reads_nested_attrs() {
let mut node = Node::new(0, "heading");
let mut attrs = HashMap::new();
attrs.insert("level".to_string(), Value::Int(3));
node.extra_fields
.insert("attrs".to_string(), Value::Unknown(attrs));
let expr = panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Concat,
vec![
panproto_expr::Expr::Lit(panproto_expr::Literal::Str("h".to_string())),
panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::IntToStr,
vec![panproto_expr::Expr::Var(std::sync::Arc::from(
"attrs.level",
))],
),
],
);
let transform = FieldTransform::ComputeField {
target_key: "name".to_string(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Opaque,
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("name"),
Some(&Value::Str("h3".into()))
);
}
#[test]
#[allow(clippy::expect_used)]
fn case_transform_sets_field_conditionally() {
use crate::value::Value;
use panproto_expr::{BuiltinOp, Expr, Literal};
use std::sync::Arc;
let mut node = Node::new(0, "heading");
node.extra_fields.insert("level".into(), Value::Int(1));
node.extra_fields
.insert("name".into(), Value::Str("heading".into()));
let case = FieldTransform::Case {
branches: vec![
CaseBranch {
predicate: Expr::builtin(
BuiltinOp::Eq,
vec![Expr::Var(Arc::from("level")), Expr::Lit(Literal::Int(1))],
),
transforms: vec![FieldTransform::ComputeField {
target_key: "name".into(),
expr: Expr::Lit(Literal::Str("h1".into())),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Opaque,
}],
},
CaseBranch {
predicate: Expr::builtin(
BuiltinOp::Eq,
vec![Expr::Var(Arc::from("level")), Expr::Lit(Literal::Int(2))],
),
transforms: vec![FieldTransform::ComputeField {
target_key: "name".into(),
expr: Expr::Lit(Literal::Str("h2".into())),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Opaque,
}],
},
],
};
apply_field_transforms(&mut node, &[case], &HashMap::new())
.expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("name"),
Some(&Value::Str("h1".into()))
);
}
fn instance_with_scalar_children() -> (WInstance, HashMap<String, Value>) {
let mut nodes = HashMap::new();
nodes.insert(0, Node::new(0, "body"));
nodes.insert(
1,
Node::new(1, "body.repo").with_value(FieldPresence::Present(Value::Str(
"at://did:plc:abc/app.bsky.feed.post/rkey123".into(),
))),
);
nodes.insert(
2,
Node::new(2, "body.text")
.with_value(FieldPresence::Present(Value::Str("hello world".into()))),
);
let edge_repo = Edge {
src: "body".into(),
tgt: "body.repo".into(),
kind: "prop".into(),
name: Some("repo".into()),
};
let edge_text = Edge {
src: "body".into(),
tgt: "body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
};
let arcs = vec![(0, 1, edge_repo), (0, 2, edge_text)];
let instance = WInstance::new(nodes, arcs, vec![], 0, "body".into());
let scalars = collect_scalar_child_values(&instance, 0);
(instance, scalars)
}
#[test]
#[allow(clippy::expect_used)]
fn compute_field_reads_scalar_child() {
let (_instance, scalars) = instance_with_scalar_children();
let mut node = Node::new(0, "body");
let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
let transform = FieldTransform::ComputeField {
target_key: "repo_copy".to_string(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &scalars)
.expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("repo_copy"),
Some(&Value::Str(
"at://did:plc:abc/app.bsky.feed.post/rkey123".into()
)),
"ComputeField should read scalar child value via dependent-sum projection"
);
}
#[test]
#[allow(clippy::expect_used)]
fn apply_expr_on_scalar_child() {
let (_instance, scalars) = instance_with_scalar_children();
let mut node = Node::new(0, "body");
let expr = panproto_expr::Expr::Builtin(
panproto_expr::BuiltinOp::Concat,
vec![
panproto_expr::Expr::Var(std::sync::Arc::from("text")),
panproto_expr::Expr::Lit(panproto_expr::Literal::Str("!".into())),
],
);
let transform = FieldTransform::ApplyExpr {
key: "text".to_string(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &scalars)
.expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("text"),
Some(&Value::Str("hello world!".into())),
"ApplyExpr should read child scalar and write result to extra_fields"
);
}
#[test]
#[allow(clippy::expect_used)]
fn case_branch_on_scalar_child() {
use panproto_expr::{BuiltinOp, Expr, Literal};
use std::sync::Arc;
let (_instance, scalars) = instance_with_scalar_children();
let mut node = Node::new(0, "body");
let case = FieldTransform::Case {
branches: vec![CaseBranch {
predicate: Expr::builtin(
BuiltinOp::Contains,
vec![
Expr::Var(Arc::from("repo")),
Expr::Lit(Literal::Str("did:plc".into())),
],
),
transforms: vec![FieldTransform::AddField {
key: "has_did".into(),
value: Value::Bool(true),
}],
}],
};
apply_field_transforms(&mut node, &[case], &scalars).expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("has_did"),
Some(&Value::Bool(true)),
"Case predicate should evaluate against child scalar values"
);
}
#[test]
#[allow(clippy::expect_used)]
fn drop_field_on_extra_field_still_works() {
let mut node = Node::new(0, "v");
node.extra_fields
.insert("keep".into(), Value::Str("yes".into()));
node.extra_fields
.insert("drop_me".into(), Value::Str("bye".into()));
let transform = FieldTransform::DropField {
key: "drop_me".into(),
};
apply_field_transforms(&mut node, &[transform], &HashMap::new())
.expect("transform should evaluate");
assert!(node.extra_fields.contains_key("keep"));
assert!(!node.extra_fields.contains_key("drop_me"));
}
#[test]
#[allow(clippy::expect_used)]
fn child_scalars_do_not_override_extra_fields() {
let mut node = Node::new(0, "v");
node.extra_fields
.insert("repo".into(), Value::Str("from_extra_fields".into()));
let mut child_scalars = HashMap::new();
child_scalars.insert("repo".into(), Value::Str("from_child".into()));
let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
let transform = FieldTransform::ComputeField {
target_key: "repo_copy".to_string(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
apply_field_transforms(&mut node, &[transform], &child_scalars)
.expect("transform should evaluate");
assert_eq!(
node.extra_fields.get("repo_copy"),
Some(&Value::Str("from_extra_fields".into())),
"extra_fields must take precedence over child_scalars"
);
}
#[test]
fn collect_scalar_child_values_completeness() {
let (instance, scalars) = instance_with_scalar_children();
assert_eq!(scalars.len(), 2, "should collect both scalar children");
assert_eq!(
scalars.get("repo"),
Some(&Value::Str(
"at://did:plc:abc/app.bsky.feed.post/rkey123".into()
))
);
assert_eq!(scalars.get("text"), Some(&Value::Str("hello world".into())));
assert!(collect_scalar_child_values(&instance, 99).is_empty());
}
#[test]
fn env_monotonicity() {
let mut extra = HashMap::new();
extra.insert("alpha".into(), Value::Str("a".into()));
extra.insert("beta".into(), Value::Int(42));
let mut children = HashMap::new();
children.insert("gamma".into(), Value::Str("g".into()));
children.insert("delta".into(), Value::Bool(true));
let env_base = build_env_from_extra_fields(&extra);
let env_extended = build_env_with_children(&extra, &children);
let config = panproto_expr::EvalConfig::default();
for key in ["alpha", "beta"] {
let var = panproto_expr::Expr::Var(std::sync::Arc::from(key));
let base_result = panproto_expr::eval(&var, &env_base, &config).ok();
let ext_result = panproto_expr::eval(&var, &env_extended, &config).ok();
assert_eq!(
base_result, ext_result,
"binding for {key} must match between base and extended env"
);
}
for key in ["gamma", "delta"] {
let var = panproto_expr::Expr::Var(std::sync::Arc::from(key));
assert!(
panproto_expr::eval(&var, &env_extended, &config).is_ok(),
"extended env should bind child scalar {key}"
);
}
}
#[test]
#[allow(clippy::expect_used)]
fn compute_field_deterministic() {
let (_instance, scalars) = instance_with_scalar_children();
let expr = panproto_expr::Expr::Var(std::sync::Arc::from("repo"));
let transform = FieldTransform::ComputeField {
target_key: "derived".to_string(),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
let mut node1 = Node::new(0, "body");
apply_field_transforms(&mut node1, std::slice::from_ref(&transform), &scalars)
.expect("transform should evaluate");
let result1 = node1.extra_fields.get("derived").cloned();
let mut node2 = Node::new(0, "body");
apply_field_transforms(&mut node2, std::slice::from_ref(&transform), &scalars)
.expect("transform should evaluate");
let result2 = node2.extra_fields.get("derived").cloned();
assert_eq!(result1, result2, "ComputeField must be deterministic");
}
#[test]
#[allow(clippy::expect_used)]
fn field_transform_term_equivalence() {
use panproto_expr::{BuiltinOp, Expr, Literal};
use std::sync::Arc;
fn fixture() -> Node {
let mut node = Node::new(0, "row");
node.extra_fields
.insert("a".into(), Value::Str("hello".into()));
node.extra_fields.insert(
"refs".into(),
Value::List(vec![Value::Str("x".into()), Value::Str("keep".into())]),
);
let mut attrs = HashMap::new();
attrs.insert("k".into(), Value::Int(1));
node.extra_fields
.insert("attrs".into(), Value::Unknown(attrs));
node
}
let variants: Vec<FieldTransform> = vec![
FieldTransform::RenameField {
old_key: "a".into(),
new_key: "b".into(),
},
FieldTransform::DropField { key: "a".into() },
FieldTransform::AddField {
key: "c".into(),
value: Value::Int(7),
},
FieldTransform::KeepFields {
keys: vec!["a".into()],
},
FieldTransform::ApplyExpr {
key: "a".into(),
expr: Expr::Builtin(
BuiltinOp::Concat,
vec![
Expr::Var(Arc::from("a")),
Expr::Lit(Literal::Str("!".into())),
],
),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Opaque,
},
FieldTransform::ComputeField {
target_key: "d".into(),
expr: Expr::Var(Arc::from("a")),
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
},
FieldTransform::PathTransform {
path: vec!["attrs".into()],
inner: Box::new(FieldTransform::RenameField {
old_key: "k".into(),
new_key: "kk".into(),
}),
},
FieldTransform::MapReferences {
field: "refs".into(),
rename_map: HashMap::from([("x".to_string(), Some("y".to_string()))]),
},
FieldTransform::Case {
branches: vec![CaseBranch {
predicate: Expr::Lit(Literal::Bool(true)),
transforms: vec![FieldTransform::AddField {
key: "flag".into(),
value: Value::Bool(true),
}],
}],
},
];
for ft in &variants {
let mut direct = fixture();
apply_field_transforms(&mut direct, std::slice::from_ref(ft), &HashMap::new())
.expect("transform should evaluate");
let assignment = TermAssignment::from_field_transform(ft);
let mut via_term = fixture();
apply_field_transforms(
&mut via_term,
std::slice::from_ref(&assignment.to_field_transform()),
&HashMap::new(),
)
.expect("transform should evaluate");
assert_eq!(
direct.extra_fields, via_term.extra_fields,
"term-assignment path must match direct field transform for {ft:?}",
);
let mut row = fixture().extra_fields;
let mut expected = fixture();
apply_field_transforms(&mut expected, std::slice::from_ref(ft), &HashMap::new())
.expect("transform should evaluate");
apply_term_assignments_to_row(&mut row, std::slice::from_ref(&assignment))
.expect("transform should evaluate");
assert_eq!(
row, expected.extra_fields,
"flat-row term substitution must match for {ft:?}",
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod property {
use super::*;
use proptest::prelude::*;
fn arb_instance_with_scalars()
-> impl Strategy<Value = (WInstance, HashMap<String, Value>, Vec<String>)> {
(1..=5usize).prop_flat_map(|n| {
prop::collection::vec("[a-z]{1,8}".prop_map(String::from), n..=n).prop_flat_map(
move |values| {
prop::collection::vec("[a-z]{1,6}".prop_map(String::from), n..=n).prop_map(
move |names| {
let values = values.clone();
let mut seen = std::collections::HashSet::new();
let deduped: Vec<String> = names
.iter()
.map(|name| {
let mut candidate = name.clone();
let mut i = 0;
while seen.contains(&candidate) {
candidate = format!("{name}{i}");
i += 1;
}
seen.insert(candidate.clone());
candidate
})
.collect();
let mut nodes = HashMap::new();
nodes.insert(0, Node::new(0, "root"));
let mut arcs = Vec::new();
for (i, (name, val)) in
deduped.iter().zip(values.iter()).enumerate()
{
let nid = u32::try_from(i + 1).unwrap();
let anchor = format!("root.{name}");
nodes.insert(
nid,
Node::new(nid, anchor.as_str()).with_value(
FieldPresence::Present(Value::Str(val.clone())),
),
);
arcs.push((
0,
nid,
Edge {
src: "root".into(),
tgt: Name::from(anchor.as_str()),
kind: "prop".into(),
name: Some(Name::from(name.as_str())),
},
));
}
let instance =
WInstance::new(nodes, arcs, vec![], 0, "root".into());
let scalars = collect_scalar_child_values(&instance, 0);
(instance, scalars, deduped)
},
)
},
)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn prop_child_scalar_collection_complete(
(_instance, scalars, names) in arb_instance_with_scalars()
) {
for name in &names {
prop_assert!(
scalars.contains_key(name),
"child scalar {name} missing from collection"
);
}
prop_assert_eq!(
scalars.len(), names.len(),
"scalar count must match child count"
);
}
#[test]
#[allow(clippy::expect_used)]
fn prop_compute_field_reads_any_child(
(_instance, scalars, names) in arb_instance_with_scalars()
) {
for name in &names {
let expr = panproto_expr::Expr::Var(std::sync::Arc::from(name.as_str()));
let transform = FieldTransform::ComputeField {
target_key: format!("{name}_copy"),
expr,
inverse: None,
coercion_class: panproto_gat::CoercionClass::Projection,
};
let mut node = Node::new(0, "root");
apply_field_transforms(&mut node, &[transform], &scalars).expect("transform should evaluate");
let expected = scalars.get(name);
let actual = node.extra_fields.get(&format!("{name}_copy"));
prop_assert_eq!(
actual, expected,
"ComputeField should read child scalar"
);
}
}
#[test]
fn prop_env_monotonicity(
(_instance, scalars, _names) in arb_instance_with_scalars()
) {
let mut extra = HashMap::new();
extra.insert("sentinel".into(), Value::Str("sentinel_val".into()));
let env_base = build_env_from_extra_fields(&extra);
let env_extended = build_env_with_children(&extra, &scalars);
let var = panproto_expr::Expr::Var(std::sync::Arc::from("sentinel"));
let config = panproto_expr::EvalConfig::default();
let base_result = panproto_expr::eval(&var, &env_base, &config).ok();
let ext_result = panproto_expr::eval(&var, &env_extended, &config).ok();
prop_assert_eq!(
base_result, ext_result,
"existing extra_field binding must be preserved"
);
}
#[test]
fn prop_identity_restrict_preserves_all_values(
(instance, _scalars, _names) in arb_instance_with_scalars()
) {
use smallvec::SmallVec;
let mut vertices = HashMap::new();
let mut edges_map = HashMap::new();
let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
let mut incoming: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
let mut between: HashMap<(Name, Name), SmallVec<Edge, 2>> = HashMap::new();
for node in instance.nodes.values() {
vertices.insert(
node.anchor.clone(),
panproto_schema::Vertex {
id: node.anchor.clone(),
kind: if node.value.is_some() { "string".into() } else { "object".into() },
nsid: None,
},
);
}
for (p, c, e) in &instance.arcs {
let _ = p;
let _ = c;
edges_map.insert(e.clone(), e.kind.clone());
outgoing.entry(e.src.clone()).or_default().push(e.clone());
incoming.entry(e.tgt.clone()).or_default().push(e.clone());
between.entry((e.src.clone(), e.tgt.clone())).or_default().push(e.clone());
}
let schema = panproto_schema::Schema {
protocol: "test".into(),
vertices,
edges: edges_map,
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
entries: Vec::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing,
incoming,
between,
};
let surviving_verts = schema.vertices.keys().cloned().collect();
let surviving_edges = schema.edges.keys().cloned().collect();
let migration = CompiledMigration {
surviving_verts,
surviving_edges,
vertex_remap: HashMap::new(),
edge_remap: HashMap::new(),
resolver: HashMap::new(),
hyper_resolver: HashMap::new(),
field_transforms: HashMap::new(),
conditional_survival: HashMap::new(),
op_term_assignments: HashMap::new(),
expansion_path: HashMap::new(),
};
let result = wtype_restrict(&instance, &schema, &schema, &migration);
prop_assert!(result.is_ok(), "identity restrict should succeed");
let restricted = result.unwrap();
prop_assert_eq!(
restricted.node_count(), instance.node_count(),
"identity restrict must preserve node count"
);
for (&id, node) in &instance.nodes {
let r_node = restricted.nodes.get(&id).unwrap();
prop_assert_eq!(&node.anchor, &r_node.anchor);
prop_assert_eq!(&node.value, &r_node.value);
prop_assert_eq!(&node.extra_fields, &r_node.extra_fields);
}
}
}
}
}