use std::collections::BTreeMap;
use knut_bifrost::{GraphPlan, PlanMode};
use crate::error::Result;
use crate::ir::{Dataset, Flow, Materialization, OutputType, Pipeline};
pub mod prop {
pub const NODE_LABEL: &str = "knut.node.label";
pub const NODE_KEY: &str = "knut.node.key";
pub const NODE_PROPS: &str = "knut.node.props";
pub const NODE_TABLE: &str = "knut.node.table";
pub const EDGE_REL: &str = "knut.edge.rel";
pub const EDGE_FROM: &str = "knut.edge.from";
pub const EDGE_TO: &str = "knut.edge.to";
pub const EDGE_FROM_KEY: &str = "knut.edge.from_key";
pub const EDGE_TO_KEY: &str = "knut.edge.to_key";
pub const EDGE_FROM_COL: &str = "knut.edge.from_col";
pub const EDGE_TO_COL: &str = "knut.edge.to_col";
pub const EDGE_TABLE: &str = "knut.edge.table";
pub const BATCH_ROWS: &str = "knut.graph.batch_rows";
pub const TIER: &str = "knut.graph.tier";
pub const SINK: &str = "knut.graph.sink";
pub const SOURCE: &str = "knut.graph.source";
}
pub fn to_thund_pipeline(plan: &GraphPlan) -> Result<Pipeline> {
let mut p = Pipeline::new("rel2graph");
p.storage = Some(plan.checkpoint.clone());
let mat = match plan.mode {
PlanMode::Stream => Materialization::Incremental,
PlanMode::Batch => Materialization::Full,
};
let sink_fmt = plan.sink.token();
let batch = plan.batch_rows.to_string();
for n in &plan.nodes {
let ds_name = node_dataset(&n.label);
let mut props: BTreeMap<String, String> = BTreeMap::new();
props.insert(prop::NODE_LABEL.into(), n.label.clone());
props.insert(prop::NODE_KEY.into(), n.key.clone());
props.insert(prop::NODE_PROPS.into(), n.props.join(","));
props.insert(prop::NODE_TABLE.into(), n.table.clone());
props.insert(prop::BATCH_ROWS.into(), batch.clone());
props.insert(prop::TIER.into(), n.tier.token().into());
props.insert(prop::SINK.into(), sink_fmt.into());
props.insert(prop::SOURCE.into(), plan.source.token().into());
let ds = Dataset::new(ds_name.clone(), OutputType::Sink)
.with_format(format!("{sink_fmt}-node"))
.with_properties(props);
let ds = with_materialization(ds, mat);
p = p.with_dataset(ds);
let mut cols = vec![n.key.clone()];
cols.extend(n.props.iter().cloned());
p = p.with_flow(
Flow::batch(format!("flow_{ds_name}"), ds_name, [n.table.clone()])
.with_projection(cols),
);
}
for e in &plan.edges {
let ds_name = edge_dataset(&e.from_label, &e.rel, &e.to_label);
let mut props: BTreeMap<String, String> = BTreeMap::new();
props.insert(prop::EDGE_REL.into(), e.rel.clone());
props.insert(prop::EDGE_FROM.into(), e.from_label.clone());
props.insert(prop::EDGE_TO.into(), e.to_label.clone());
props.insert(prop::EDGE_FROM_KEY.into(), e.from_key.clone());
props.insert(prop::EDGE_TO_KEY.into(), e.to_key.clone());
props.insert(prop::EDGE_FROM_COL.into(), e.from_col.clone());
props.insert(prop::EDGE_TO_COL.into(), e.to_col.clone());
props.insert(prop::EDGE_TABLE.into(), e.table.clone());
props.insert(prop::BATCH_ROWS.into(), batch.clone());
props.insert(prop::TIER.into(), e.tier.token().into());
props.insert(prop::SINK.into(), sink_fmt.into());
props.insert(prop::SOURCE.into(), plan.source.token().into());
let ds = Dataset::new(ds_name.clone(), OutputType::Sink)
.with_format(format!("{sink_fmt}-edge"))
.with_properties(props);
let ds = with_materialization(ds, mat);
p = p.with_dataset(ds);
let mut cols = vec![e.from_col.clone(), e.to_col.clone()];
cols.extend(e.props.iter().cloned());
p = p.with_flow(
Flow::batch(format!("flow_{ds_name}"), ds_name, [e.table.clone()])
.with_projection(cols),
);
}
p.validate()?;
Ok(p)
}
pub fn to_pipeline_ron(plan: &GraphPlan) -> Result<String> {
let p = to_thund_pipeline(plan)?;
crate::authoring::dsl::to_ron(&p)
}
fn with_materialization(ds: Dataset, mat: Materialization) -> Dataset {
match mat {
Materialization::Incremental => ds.incremental(),
Materialization::Full => ds, }
}
fn node_dataset(label: &str) -> String {
format!("node__{}", sanitize(label))
}
fn edge_dataset(from: &str, rel: &str, to: &str) -> String {
format!(
"edge__{}__{}__{}",
sanitize(from),
sanitize(rel),
sanitize(to)
)
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use knut_bifrost::MappingSpec;
use knut_bifrost::codegen::CodegenOptions;
fn sample_plan(opts: &CodegenOptions) -> GraphPlan {
let yaml = r#"
version: 1
tables:
- table: lake.social.users
pipeline:
- vertex: { label: User, id_column: user_id, properties: [name, age] }
- fk_edges:
type: WORKS_AT
src: { label: User, column: user_id }
dst: { label: Company, column: company_id }
properties: [since]
- table: lake.social.companies
pipeline:
- vertex: { label: Company, id_column: company_id, properties: [name] }
"#;
let spec = MappingSpec::from_yaml(yaml).unwrap();
GraphPlan::from_mapping(&spec, opts).unwrap()
}
fn nodes_from_pyspark(py: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for line in py.lines() {
if let Some(rest) = line.split_once("CREATE INDEX FOR (n:`") {
let (label, tail) = rest.1.split_once("`) ON (n.`").unwrap();
let key = tail.split_once("`)").unwrap().0;
out.push((label.to_string(), key.to_string()));
}
}
out.sort();
out
}
fn edges_from_pyspark(py: &str) -> Vec<(String, String, String)> {
let mut out = Vec::new();
for line in py.lines() {
if line.trim_start().starts_with("def ") {
continue;
}
if let Some((_, rest)) = line.split_once("_write_edges(") {
let args: Vec<&str> = rest.split(", ").collect();
let unq = |s: &str| s.trim().trim_matches('"').to_string();
out.push((unq(args[0]), unq(args[1]), unq(args[2])));
}
}
out.sort();
out
}
fn batch_from_pyspark(py: &str) -> String {
py.lines()
.find_map(|l| l.strip_prefix("BATCH_ROWS = "))
.unwrap()
.to_string()
}
fn nodes_from_pipeline(p: &Pipeline) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = p
.datasets
.iter()
.filter_map(|d| {
Some((
d.properties.get(prop::NODE_LABEL)?.clone(),
d.properties.get(prop::NODE_KEY)?.clone(),
))
})
.collect();
out.sort();
out
}
fn edges_from_pipeline(p: &Pipeline) -> Vec<(String, String, String)> {
let mut out: Vec<(String, String, String)> = p
.datasets
.iter()
.filter_map(|d| {
Some((
d.properties.get(prop::EDGE_FROM)?.clone(),
d.properties.get(prop::EDGE_REL)?.clone(),
d.properties.get(prop::EDGE_TO)?.clone(),
))
})
.collect();
out.sort();
out
}
fn batch_from_pipeline(p: &Pipeline) -> String {
p.datasets
.iter()
.find_map(|d| d.properties.get(prop::BATCH_ROWS).cloned())
.unwrap()
}
#[test]
fn two_projections_agree() {
let plan = sample_plan(&CodegenOptions::default());
let py = plan.to_pyspark();
let pipe = to_thund_pipeline(&plan).unwrap();
assert_eq!(
nodes_from_pyspark(&py),
nodes_from_pipeline(&pipe),
"node labels+keys agree across the two projections"
);
assert_eq!(
edges_from_pyspark(&py),
edges_from_pipeline(&pipe),
"edge directions (from,rel,to) agree across the two projections"
);
assert_eq!(
batch_from_pyspark(&py),
batch_from_pipeline(&pipe),
"batch size agrees across the two projections"
);
let contract = plan.contract();
assert!(contract.contains("node User=user_id"));
assert!(contract.contains("node Company=company_id"));
assert!(contract.contains("edge User-WORKS_AT->Company"));
assert!(contract.contains("batch_rows=1000"));
assert_eq!(nodes_from_pyspark(&py).len(), 2);
assert_eq!(edges_from_pyspark(&py).len(), 1);
}
#[test]
fn pipeline_is_real_thund_ir_and_round_trips() {
let plan = sample_plan(&CodegenOptions::default());
let pipe = to_thund_pipeline(&plan).unwrap();
pipe.validate()
.expect("the projected pipeline is valid thund IR");
let names: Vec<&str> = pipe.datasets.iter().map(|d| d.name.as_str()).collect();
assert_eq!(
names,
[
"node__Company",
"node__User",
"edge__User__WORKS_AT__Company"
]
);
assert!(
pipe.datasets
.iter()
.all(|d| d.output_type == OutputType::Sink)
);
let user_flow = pipe
.flows
.iter()
.find(|f| f.target == "node__User")
.unwrap();
assert_eq!(user_flow.projection, vec!["user_id", "name", "age"]);
assert_eq!(user_flow.reads, vec!["lake.social.users"]);
let ron = to_pipeline_ron(&plan).unwrap();
let back = crate::authoring::dsl::from_ron(&ron).unwrap();
assert_eq!(back, pipe, "pipeline.ron round-trips losslessly");
assert!(ron.contains("knut.node.label"));
assert!(ron.contains("WORKS_AT"));
}
#[test]
fn streaming_plan_projects_incremental() {
use knut_bifrost::{PlanMode, SourceKind};
let opts = CodegenOptions {
source: SourceKind::Iceberg,
mode: PlanMode::Stream,
..Default::default()
};
let plan = sample_plan(&opts);
let pipe = to_thund_pipeline(&plan).unwrap();
assert!(
pipe.datasets
.iter()
.all(|d| d.materialization == Materialization::Incremental),
"streaming plan → incremental datasets"
);
assert!(plan.to_pyspark().contains("foreachBatch("));
}
}