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 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>,
}
impl CompiledMigration {
#[must_use]
pub fn coercion_class(&self) -> panproto_gat::CoercionClass {
self.field_transforms
.values()
.flat_map(|ts| ts.iter())
.fold(panproto_gat::CoercionClass::Iso, |acc, t| {
acc.compose(t.coercion_class())
})
}
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)
}
#[allow(clippy::too_many_lines)]
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 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) {
if let Some(pred) = migration.conditional_survival.get(&child_node.anchor) {
let env = build_env_from_extra_fields(&child_node.extra_fields);
let config = panproto_expr::EvalConfig::default();
if matches!(
panproto_expr::eval(pred, &env, &config),
Ok(panproto_expr::Literal::Bool(false))
) {
queue.push_back((child_id, child_ancestor));
continue;
}
}
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);
}
if let Some(transforms) = migration.field_transforms.get(&child_node.anchor) {
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 {
let anc_anchor = new_nodes
.get(&anc_id)
.ok_or(RestrictError::RootPruned)?
.anchor
.clone();
let child_anchor = new_node.anchor.clone();
match resolve_edge(tgt_schema, &migration.resolver, &anc_anchor, &child_anchor)
{
Ok(edge) => {
new_arcs.push((anc_id, child_id, edge));
}
Err(restrict_err) => {
let intermediates = migration
.expansion_path
.get(&(anc_anchor.clone(), child_anchor.clone()));
if let Some(intermediates) = intermediates {
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));
} else {
return Err(restrict_err);
}
}
}
}
}
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,
))
}
pub fn apply_field_transforms(
node: &mut Node,
transforms: &[FieldTransform],
child_scalars: &HashMap<String, Value>,
) {
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();
if let Ok(result) = panproto_expr::eval(expr, &env, &config) {
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();
if let Ok(result) = panproto_expr::eval(expr, &env, &config) {
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();
if let Ok(result) = panproto_expr::eval(expr, &env, &config) {
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 branch in branches {
let result = panproto_expr::eval(&branch.predicate, &env, &config);
if matches!(result, Ok(panproto_expr::Literal::Bool(true))) {
apply_field_transforms(node, &branch.transforms, child_scalars);
break;
}
}
}
}
}
}
fn apply_path_transform(node: &mut Node, path: &[String], inner: &FieldTransform) {
let first = &path[0];
if let Some(Value::Unknown(map)) = node.extra_fields.get_mut(first) {
if path.len() == 1 {
let mut temp_node = Node::new(0, "");
temp_node.extra_fields = std::mem::take(map);
apply_field_transforms(&mut temp_node, std::slice::from_ref(inner), &HashMap::new());
*map = temp_node.extra_fields;
} else {
let rest = &path[1..];
let mut temp_node = Node::new(0, "");
temp_node.extra_fields = std::mem::take(map);
apply_path_transform(&mut temp_node, rest, inner);
*map = temp_node.extra_fields;
}
}
}
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::List(items) => {
let parts: Vec<&str> = items
.iter()
.filter_map(|item| match item {
Value::Str(s) => Some(s.as_str()),
_ => None,
})
.collect();
panproto_expr::Literal::Str(parts.join(","))
}
_ => 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()),
_ => 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);
}
}
if let Some(transforms) = migration.field_transforms.get(&root_node.anchor) {
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 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) {
continue;
}
if let Some(transforms) = migration.field_transforms.get(&node.anchor) {
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,
))
}
#[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(),
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(),
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(),
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(),
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(),
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(),
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(),
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::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(),
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(),
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(),
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]
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());
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]
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());
assert!(!node.extra_fields.contains_key("color"));
assert_eq!(
node.extra_fields.get("colour"),
Some(&Value::Str("red".into()))
);
}
#[test]
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());
assert_eq!(
node.extra_fields.get("parent"),
Some(&Value::Str("new_name".into()))
);
}
#[test]
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());
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]
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());
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_joins_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::Str(s) => assert_eq!(s, "a,b,c"),
other => panic!("expected Literal::Str, got {other:?}"),
}
}
#[test]
fn value_to_expr_literal_drops_non_string_list_elements() {
let val = Value::List(vec![
Value::Str("keep".into()),
Value::Int(42),
Value::Bool(true),
Value::Str("alsokeep".into()),
Value::Null,
]);
match value_to_expr_literal(&val) {
panproto_expr::Literal::Str(s) => assert_eq!(
s, "keep,alsokeep",
"non-string list elements should be filtered out, not Debug-formatted"
),
other => panic!("expected Literal::Str, got {other:?}"),
}
}
#[test]
fn value_to_expr_literal_empty_list_is_empty_string() {
let val = Value::List(Vec::new());
match value_to_expr_literal(&val) {
panproto_expr::Literal::Str(s) => assert!(s.is_empty()),
other => panic!("expected Literal::Str, 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!(matches!(
value_to_expr_literal(&Value::Unknown(HashMap::new())),
panproto_expr::Literal::Null
));
}
#[test]
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());
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(),
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(),
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(),
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(),
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(),
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(),
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]
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());
assert_eq!(
node.extra_fields.get("name"),
Some(&Value::Str("h2".into()))
);
}
#[test]
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());
assert_eq!(
node.extra_fields.get("name"),
Some(&Value::Str("h3".into()))
);
}
#[test]
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());
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]
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);
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]
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);
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]
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);
assert_eq!(
node.extra_fields.get("has_did"),
Some(&Value::Bool(true)),
"Case predicate should evaluate against child scalar values"
);
}
#[test]
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());
assert!(node.extra_fields.contains_key("keep"));
assert!(!node.extra_fields.contains_key("drop_me"));
}
#[test]
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);
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]
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);
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);
let result2 = node2.extra_fields.get("derived").cloned();
assert_eq!(result1, result2, "ComputeField must be deterministic");
}
#[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]
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);
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(),
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(),
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);
}
}
}
}
}