use std::fmt::Write as _;
use crate::error::{BifrostError, Result};
use crate::mapping::{MappingSpec, PipelineStep, SyncMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
Postgres,
Iceberg,
}
impl SourceKind {
pub fn token(self) -> &'static str {
match self {
SourceKind::Postgres => "postgres",
SourceKind::Iceberg => "iceberg",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SinkKind {
FalkorDb,
GraphAr,
}
impl SinkKind {
pub fn token(self) -> &'static str {
match self {
SinkKind::FalkorDb => "falkordb",
SinkKind::GraphAr => "graphar",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanMode {
Batch,
Stream,
}
impl PlanMode {
pub fn token(self) -> &'static str {
match self {
PlanMode::Batch => "batch",
PlanMode::Stream => "stream",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Tier {
T1PostgresCatalog,
T2IcebergFieldIds,
T3TblProperties,
T4InclusionDependency,
}
impl Tier {
pub fn token(self) -> &'static str {
match self {
Tier::T1PostgresCatalog => "T1",
Tier::T2IcebergFieldIds => "T2",
Tier::T3TblProperties => "T3",
Tier::T4InclusionDependency => "T4",
}
}
pub fn describe(self) -> &'static str {
match self {
Tier::T1PostgresCatalog => "upstream Postgres catalog (PK/FK constraints)",
Tier::T2IcebergFieldIds => "Iceberg identifier-field-ids",
Tier::T3TblProperties => "knut.* TBLPROPERTIES on the table",
Tier::T4InclusionDependency => {
"inclusion-dependency inference (LEFT ANTI JOIN orphan test) — PROPOSAL only"
}
}
}
}
#[derive(Debug, Clone)]
pub struct CodegenOptions {
pub source: SourceKind,
pub sink: SinkKind,
pub mode: PlanMode,
pub batch_rows: usize,
pub concurrency: usize,
pub checkpoint: String,
pub declared_tier: Tier,
}
impl Default for CodegenOptions {
fn default() -> Self {
CodegenOptions {
source: SourceKind::Iceberg,
sink: SinkKind::FalkorDb,
mode: PlanMode::Batch,
batch_rows: 1000,
concurrency: 1,
checkpoint: "/tmp/rel2graph/checkpoint".to_string(),
declared_tier: Tier::T3TblProperties,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodePlan {
pub table: String,
pub label: String,
pub key: String,
pub props: Vec<String>,
pub tier: Tier,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgePlan {
pub table: String,
pub from_label: String,
pub rel: String,
pub to_label: String,
pub from_key: String,
pub to_key: String,
pub from_col: String,
pub to_col: String,
pub props: Vec<String>,
pub tier: Tier,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgeProposal {
pub edge: EdgePlan,
pub evidence: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphPlan {
pub source: SourceKind,
pub sink: SinkKind,
pub mode: PlanMode,
pub batch_rows: usize,
pub concurrency: usize,
pub checkpoint: String,
pub nodes: Vec<NodePlan>,
pub edges: Vec<EdgePlan>,
pub proposals: Vec<EdgeProposal>,
pub warnings: Vec<String>,
}
impl GraphPlan {
pub fn from_mapping(spec: &MappingSpec, opts: &CodegenOptions) -> Result<GraphPlan> {
if opts.mode == PlanMode::Stream && opts.source != SourceKind::Iceberg {
return Err(BifrostError::Other(format!(
"MODE/SOURCE conflict: streaming needs an Iceberg source, got {:?}",
opts.source
)));
}
let mut nodes: Vec<NodePlan> = Vec::new();
let mut edges: Vec<EdgePlan> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut tables: Vec<&crate::mapping::TableMapping> = spec.tables.iter().collect();
tables.sort_by(|a, b| table_sort_key(&a.table).cmp(&table_sort_key(&b.table)));
for tm in tables {
if tm.mode == SyncMode::Cdc && opts.mode == PlanMode::Batch {
warnings.push(format!(
"table `{}` declares CDC sync but the plan mode is batch — the changelog \
is read as a full insert load (see MODE)",
tm.table
));
}
for step in &tm.pipeline {
match step {
PipelineStep::Vertex { vertex } => {
if vertex.id_column.is_empty() {
return Err(BifrostError::Other(format!(
"node `{}` (table `{}`) has no merge key",
vertex.label, tm.table
)));
}
nodes.push(NodePlan {
table: tm.table.clone(),
label: vertex.label.clone(),
key: vertex.id_column.clone(),
props: vertex.properties.clone(),
tier: opts.declared_tier,
});
}
PipelineStep::Edge { edge: t } | PipelineStep::FkEdges { fk_edges: t } => {
edges.push(EdgePlan {
table: tm.table.clone(),
from_label: t.src.label.clone(),
rel: t.edge_type.clone(),
to_label: t.dst.label.clone(),
from_key: t
.src
.id_column
.clone()
.unwrap_or_else(|| t.src.column.clone()),
to_key: t
.dst
.id_column
.clone()
.unwrap_or_else(|| t.dst.column.clone()),
from_col: t.src.column.clone(),
to_col: t.dst.column.clone(),
props: t.properties.clone(),
tier: opts.declared_tier,
});
}
PipelineStep::Sql { .. } => {
warnings.push(format!(
"table `{}` uses a `sql:` step — its output is decided by the query \
at run time and cannot be projected to a declarative graph load; wire \
it by hand on the target backend (NOT dropped)",
tm.table
));
}
PipelineStep::Custom { custom } => {
warnings.push(format!(
"table `{}` uses custom transform `{custom}` — an opaque Rust step \
that cannot be projected to a declarative graph load; wire it by \
hand on the target backend (NOT dropped)",
tm.table
));
}
}
}
}
edges.sort_by(|a, b| {
(&a.from_label, &a.rel, &a.to_label).cmp(&(&b.from_label, &b.rel, &b.to_label))
});
warnings.insert(
0,
format!(
"provenance: {} node(s) + {} edge(s) resolved at tier {} ({}); {} T4 proposal(s) \
held back (commented-out, never applied)",
nodes.len(),
edges.len(),
opts.declared_tier.token(),
opts.declared_tier.describe(),
0
),
);
Ok(GraphPlan {
source: opts.source,
sink: opts.sink,
mode: opts.mode,
batch_rows: opts.batch_rows,
concurrency: opts.concurrency,
checkpoint: opts.checkpoint.clone(),
nodes,
edges,
proposals: Vec::new(),
warnings,
})
}
pub fn with_proposals(mut self, proposals: impl IntoIterator<Item = EdgeProposal>) -> Self {
for mut p in proposals {
p.edge.tier = Tier::T4InclusionDependency;
self.proposals.push(p);
}
if let Some(first) = self.warnings.first_mut() {
if first.starts_with("provenance:") {
*first = format!(
"provenance: {} node(s) + {} edge(s) resolved; {} T4 proposal(s) held back \
(commented-out, never applied)",
self.nodes.len(),
self.edges.len(),
self.proposals.len()
);
}
}
self
}
pub fn contract(&self) -> String {
let mut s = String::new();
let _ = writeln!(s, "batch_rows={}", self.batch_rows);
for n in &self.nodes {
let _ = writeln!(s, "node {}={}", n.label, n.key);
}
for e in &self.edges {
let _ = writeln!(s, "edge {}-{}->{}", e.from_label, e.rel, e.to_label);
}
s
}
pub fn to_pyspark(&self) -> String {
let mut s = String::new();
let p = &mut s;
let _ = writeln!(
p,
"# ── rel2graph codegen · Spark backend projection ──────────────────"
);
let _ = writeln!(
p,
"# source={} sink={} mode={} batch_rows={} concurrency={}",
self.source.token(),
self.sink.token(),
self.mode.token(),
self.batch_rows,
self.concurrency
);
let _ = writeln!(
p,
"# GENERATED — one MappingSpec, two projections; edits belong upstream."
);
let _ = writeln!(p, "from pyspark.sql import SparkSession");
let _ = writeln!(p);
let _ = writeln!(p, "BATCH_ROWS = {}", self.batch_rows);
let _ = writeln!(p, "CHECKPOINT = {:?}", self.checkpoint);
let _ = writeln!(p);
let _ = writeln!(p, "def _sink():");
let _ = writeln!(
p,
" # sink client constructed INSIDE the partition fn (never shipped from the driver)"
);
match self.sink {
SinkKind::FalkorDb => {
let _ = writeln!(p, " from falkordb import FalkorDB");
let _ = writeln!(
p,
" return FalkorDB(host='localhost', port=6379).select_graph('rel2graph')"
);
}
SinkKind::GraphAr => {
let _ = writeln!(p, " import pygraphar # columnar graph writer");
let _ = writeln!(p, " return pygraphar.Writer('rel2graph')");
}
}
let _ = writeln!(p);
let _ = writeln!(p, "def _write_nodes(label, key, rows):");
let _ = writeln!(p, " g = _sink(); buf = []");
let _ = writeln!(p, " def flush():");
let _ = writeln!(p, " if not buf: return");
p.push_str(
" g.query('UNWIND $rows AS r MERGE (n:`' + label + '` {`' + key + '`: r[\"' + key + '\"]}) SET n += r', {'rows': buf})\n",
);
let _ = writeln!(p, " buf.clear()");
let _ = writeln!(p, " for row in rows:");
let _ = writeln!(p, " buf.append(row.asDict())");
let _ = writeln!(p, " if len(buf) >= BATCH_ROWS: flush()");
let _ = writeln!(p, " flush()");
let _ = writeln!(p);
let _ = writeln!(p, "def _write_edges(fl, rel, tl, fk, tk, rows):");
let _ = writeln!(p, " g = _sink(); buf = []");
let _ = writeln!(p, " def flush():");
let _ = writeln!(p, " if not buf: return");
p.push_str(
" g.query('UNWIND $rows AS r MERGE (a:`' + fl + '` {`' + fk + '`: r.src}) MERGE (b:`' + tl + '` {`' + tk + '`: r.dst}) MERGE (a)-[e:`' + rel + '`]->(b) SET e += r.props', {'rows': buf})\n",
);
let _ = writeln!(p, " buf.clear()");
let _ = writeln!(p, " for row in rows:");
let _ = writeln!(
p,
" d = row.asDict(); props = {{k: v for k, v in d.items() if k not in ('src', 'dst')}}"
);
let _ = writeln!(
p,
" buf.append({{'src': d['src'], 'dst': d['dst'], 'props': props}})"
);
let _ = writeln!(p, " if len(buf) >= BATCH_ROWS: flush()");
let _ = writeln!(p, " flush()");
let _ = writeln!(p);
let _ = writeln!(p, "def ensure_indexes(g):");
if self.nodes.is_empty() {
let _ = writeln!(p, " pass # no nodes");
}
for n in &self.nodes {
let _ = writeln!(
p,
" g.query('CREATE INDEX FOR (n:`{}`) ON (n.`{}`)') # {}",
n.label,
n.key,
n.tier.token()
);
}
let _ = writeln!(p);
let _ = writeln!(p, "def main():");
let _ = writeln!(
p,
" spark = SparkSession.builder.config('spark.scheduler.mode', 'FAIR').getOrCreate()"
);
let _ = writeln!(p, " ensure_indexes(_sink())");
let stream = self.mode == PlanMode::Stream;
let reader = if stream { "readStream.table" } else { "table" };
let _ = writeln!(p, " # ── nodes (before edges) ──");
for n in &self.nodes {
let cols = node_select_cols(n);
let df = format!("df_node_{}", sanitize(&n.label));
let _ = writeln!(
p,
" {df} = spark.{reader}({:?}).selectExpr({cols})",
n.table
);
if stream {
let _ = writeln!(
p,
" {df}.writeStream.option('checkpointLocation', CHECKPOINT + '/node_{}').foreachBatch(",
sanitize(&n.label)
);
let _ = writeln!(
p,
" lambda b, _: b.coalesce({}).foreachPartition(lambda rows: _write_nodes({:?}, {:?}, rows))).start()",
self.concurrency, n.label, n.key
);
} else {
let _ = writeln!(
p,
" {df}.coalesce({}).foreachPartition(lambda rows: _write_nodes({:?}, {:?}, rows))",
self.concurrency, n.label, n.key
);
}
}
let _ = writeln!(p, " # ── edges ──");
for e in &self.edges {
let cols = edge_select_cols(e);
let df = format!(
"df_edge_{}_{}_{}",
sanitize(&e.from_label),
sanitize(&e.rel),
sanitize(&e.to_label)
);
let _ = writeln!(
p,
" {df} = spark.{reader}({:?}).selectExpr({cols})",
e.table
);
if stream {
let _ = writeln!(
p,
" {df}.writeStream.option('checkpointLocation', CHECKPOINT + '/edge_{}_{}_{}').foreachBatch(",
sanitize(&e.from_label),
sanitize(&e.rel),
sanitize(&e.to_label)
);
let _ = writeln!(
p,
" lambda b, _: b.coalesce({}).foreachPartition(lambda rows: _write_edges({:?}, {:?}, {:?}, {:?}, {:?}, rows))).start()",
self.concurrency, e.from_label, e.rel, e.to_label, e.from_key, e.to_key
);
} else {
let _ = writeln!(
p,
" {df}.coalesce({}).foreachPartition(lambda rows: _write_edges({:?}, {:?}, {:?}, {:?}, {:?}, rows))",
self.concurrency, e.from_label, e.rel, e.to_label, e.from_key, e.to_key
);
}
}
if stream {
let _ = writeln!(p, " spark.streams.awaitAnyTermination()");
}
let _ = writeln!(p);
let _ = writeln!(p, "if __name__ == '__main__':");
let _ = writeln!(p, " main()");
self.emit_proposals_py(p);
self.emit_warnings(p, "#");
s
}
fn emit_proposals_py(&self, p: &mut String) {
if self.proposals.is_empty() {
return;
}
let _ = writeln!(p);
let _ = writeln!(
p,
"# ── T4 PROPOSALS (inclusion-dependency inference) — NOT applied ──"
);
let _ = writeln!(
p,
"# Confirm the evidence, then add upstream in the MappingSpec. Never auto-applied."
);
for pr in &self.proposals {
let e = &pr.edge;
let _ = writeln!(
p,
"# ({}:`{}`)-[:`{}`]->({}:`{}`) evidence: {}",
sanitize(&e.from_label),
e.from_label,
e.rel,
sanitize(&e.to_label),
e.to_label,
pr.evidence
);
}
}
pub fn emit_warnings(&self, p: &mut String, line_prefix: &str) {
let _ = writeln!(p);
let _ = writeln!(p, "{line_prefix} ── WARNINGS ──");
if self.warnings.is_empty() {
let _ = writeln!(p, "{line_prefix} (none)");
}
for w in &self.warnings {
let _ = writeln!(p, "{line_prefix} {w}");
}
}
}
fn table_sort_key(table: &str) -> (String, String) {
match table.rsplit_once('.') {
Some((ns, leaf)) => (ns.to_string(), leaf.to_string()),
None => (String::new(), table.to_string()),
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn node_select_cols(n: &NodePlan) -> String {
let mut cols = vec![format!("\"`{}`\"", n.key)];
cols.extend(n.props.iter().map(|c| format!("\"`{c}`\"")));
cols.join(", ")
}
fn edge_select_cols(e: &EdgePlan) -> String {
let mut parts = vec![
format!("\"`{}` AS src\"", e.from_col),
format!("\"`{}` AS dst\"", e.to_col),
];
for c in &e.props {
parts.push(format!("\"`{c}`\""));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mapping::MappingSpec;
fn sample_spec() -> MappingSpec {
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] }
"#;
MappingSpec::from_yaml(yaml).expect("sample yaml parses")
}
#[test]
fn plan_is_canonical_and_deterministic() {
let spec = sample_spec();
let opts = CodegenOptions::default();
let a = GraphPlan::from_mapping(&spec, &opts).unwrap();
let b = GraphPlan::from_mapping(&spec, &opts).unwrap();
assert_eq!(a.to_pyspark(), b.to_pyspark(), "codegen is deterministic");
assert_eq!(a.nodes.len(), 2);
assert_eq!(a.nodes[0].label, "Company");
assert_eq!(a.nodes[1].label, "User");
assert_eq!(a.nodes[1].props, vec!["name", "age"]);
assert_eq!(a.edges.len(), 1);
let e = &a.edges[0];
assert_eq!(
(e.from_label.as_str(), e.rel.as_str(), e.to_label.as_str()),
("User", "WORKS_AT", "Company")
);
assert_eq!(e.from_key, "user_id");
assert_eq!(e.to_key, "company_id");
}
#[test]
fn pyspark_obeys_the_python_hard_rules() {
let plan = GraphPlan::from_mapping(&sample_spec(), &CodegenOptions::default()).unwrap();
let py = plan.to_pyspark();
assert!(py.contains("spark.scheduler.mode"), "FAIR scheduler set");
assert!(py.contains("'FAIR'"));
assert!(py.contains("ensure_indexes"), "indexes created at startup");
assert!(py.contains("CREATE INDEX FOR (n:`User`) ON (n.`user_id`)"));
assert!(
py.contains("def _sink():"),
"sink client built inside partition fn"
);
assert!(
py.contains(".coalesce(1).foreachPartition("),
"coalesce before foreachPartition"
);
assert!(
py.contains("UNWIND $rows AS r MERGE"),
"idempotent UNWIND MERGE"
);
assert!(py.contains("if len(buf) >= BATCH_ROWS:"), "batched writes");
assert!(py.contains("BATCH_ROWS = 1000"));
assert!(
py.contains("MERGE (n:`' + label + '` {`' + key + '`: r[\"' + key + '\"]})"),
"node MERGE keeps its single-brace Cypher: {py}"
);
assert!(
py.contains("MERGE (a:`' + fl + '` {`' + fk + '`: r.src})"),
"edge MERGE keeps its single-brace Cypher"
);
assert!(
py.contains("{'rows': buf}"),
"the params dict uses single braces"
);
assert!(!py.contains("{{"), "no doubled `{{` leaked into the output");
assert!(!py.contains("}}"), "no doubled `}}` leaked into the output");
let main = &py[py.find("def main()").unwrap()..];
let node_at = main.find("_write_nodes").unwrap();
let edge_at = main.find("_write_edges").unwrap();
assert!(node_at < edge_at, "nodes are loaded before edges");
assert!(py.trim_end().contains("── WARNINGS ──"));
assert!(py.contains("provenance:"));
}
#[test]
fn streaming_needs_iceberg_and_wraps_one_foreachbatch() {
let spec = sample_spec();
let bad = CodegenOptions {
source: SourceKind::Postgres,
mode: PlanMode::Stream,
..Default::default()
};
assert!(
GraphPlan::from_mapping(&spec, &bad).is_err(),
"stream needs iceberg"
);
let ok = CodegenOptions {
source: SourceKind::Iceberg,
mode: PlanMode::Stream,
..Default::default()
};
let py = GraphPlan::from_mapping(&spec, &ok).unwrap().to_pyspark();
assert!(py.contains("readStream.table"), "streaming reader");
assert!(
py.contains("foreachBatch("),
"foreachBatch wraps the per-partition writer"
);
assert!(
py.contains("checkpointLocation"),
"one checkpointLocation per query"
);
assert!(py.contains("awaitAnyTermination"));
}
#[test]
fn custom_step_is_surfaced_not_dropped() {
let yaml = r#"
version: 1
tables:
- table: lake.x.t
pipeline:
- vertex: { label: T, id_column: id }
- custom: my_special_transform
"#;
let spec = MappingSpec::from_yaml(yaml).unwrap();
let plan = GraphPlan::from_mapping(&spec, &CodegenOptions::default()).unwrap();
assert_eq!(plan.nodes.len(), 1);
assert!(
plan.warnings
.iter()
.any(|w| w.contains("my_special_transform") && w.contains("NOT dropped")),
"the opaque custom step is surfaced in WARNINGS: {:?}",
plan.warnings
);
}
#[test]
fn t4_proposals_are_emitted_commented_out_never_applied() {
let plan = GraphPlan::from_mapping(&sample_spec(), &CodegenOptions::default())
.unwrap()
.with_proposals([EdgeProposal {
edge: EdgePlan {
table: "lake.social.users".into(),
from_label: "User".into(),
rel: "REFERRED_BY".into(),
to_label: "User".into(),
from_key: "user_id".into(),
to_key: "user_id".into(),
from_col: "user_id".into(),
to_col: "referrer_id".into(),
props: vec![],
tier: Tier::T1PostgresCatalog, },
evidence: "LEFT ANTI JOIN found 0 orphan referrer_id rows (n=10000)".into(),
}]);
assert_eq!(plan.proposals[0].edge.tier, Tier::T4InclusionDependency);
let py = plan.to_pyspark();
assert!(py.contains("T4 PROPOSALS"), "proposals section present");
assert!(py.contains("REFERRED_BY"), "the proposal is shown");
assert!(py.contains("evidence:"), "with its evidence");
assert!(
!py.contains("_write_edges(\"User\", \"REFERRED_BY\""),
"a T4 proposal is never emitted as a live edge load"
);
}
}