knut_bifrost/transform.rs
1//! The transform fabric: built-in declarative transforms plus the trait
2//! custom Rust transforms implement.
3
4use std::{collections::HashMap, sync::Arc};
5
6use arrow_array::{Array, RecordBatch, StringArray};
7use arrow_schema::{Field, Schema};
8
9use crate::{
10 cdc::ChangeOp,
11 error::{BifrostError, Result},
12 mapping::SyncMode,
13 mutation::{EdgeSet, GraphMutation, VertexDelete, VertexSet},
14};
15
16/// Information about the table a batch came from, available to transforms.
17#[derive(Debug, Clone)]
18pub struct TableContext {
19 /// Fully qualified table name, e.g. `lake.social.users`.
20 pub table: String,
21}
22
23/// One step of a pipeline: table rows in, graph elements out.
24///
25/// Implement this for cases the declarative mappings can't express, and
26/// register it under a name with [`crate::registry::TransformRegistry`].
27pub trait Transform: Send + Sync {
28 fn apply(&self, ctx: &TableContext, batches: &[RecordBatch]) -> Result<GraphMutation>;
29
30 /// The same step over a changelog round's `DELETE` rows, whose nodes and
31 /// edges are then removed. Such a row may carry only its key — every other
32 /// column null — so a step that filters or computes on other columns must
33 /// not let that drop the delete. The default is [`Self::apply`], right for
34 /// every step that only picks columns.
35 fn apply_deletes(&self, ctx: &TableContext, batches: &[RecordBatch]) -> Result<GraphMutation> {
36 self.apply(ctx, batches)
37 }
38}
39
40/// Endpoint reference used by edge transforms.
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42pub struct EndpointSpec {
43 pub label: String,
44 /// Column in *this* table holding the endpoint's id value.
45 pub column: String,
46 /// Id column on the endpoint node itself; defaults to `column`.
47 #[serde(default)]
48 pub id_column: Option<String>,
49}
50
51impl EndpointSpec {
52 fn node_id_column(&self) -> &str {
53 self.id_column.as_deref().unwrap_or(&self.column)
54 }
55}
56
57// ── vertex ──────────────────────────────────────────────────────────────────
58
59/// Rows → nodes. Selects `id_column` + `properties`, applies `rename`.
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61pub struct VertexTransform {
62 pub label: String,
63 pub id_column: String,
64 #[serde(default)]
65 pub properties: Vec<String>,
66 #[serde(default)]
67 pub rename: HashMap<String, String>,
68}
69
70impl Transform for VertexTransform {
71 fn apply(&self, _ctx: &TableContext, batches: &[RecordBatch]) -> Result<GraphMutation> {
72 let mut out = GraphMutation::default();
73 for batch in batches {
74 let mut keep = vec![self.id_column.clone()];
75 keep.extend(self.properties.iter().cloned());
76 let projected = project(batch, &keep, &self.rename)?;
77 out.vertices.push(VertexSet {
78 label: self.label.clone(),
79 id_column: renamed(&self.id_column, &self.rename),
80 batch: projected,
81 });
82 }
83 Ok(out)
84 }
85}
86
87// ── edge ────────────────────────────────────────────────────────────────────
88
89/// Rows → relationships. `src.column`/`dst.column` become the `src`/`dst`
90/// columns of the output batch; the `properties` columns ride along and the
91/// sink folds them into the relationship's `props` (`SET e += r.props`).
92/// `rename` maps a source column to the relationship-property name it lands
93/// under, mirroring [`VertexTransform`]; the endpoint columns always become
94/// `src`/`dst` regardless of `rename`.
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
96pub struct EdgeTransform {
97 #[serde(rename = "type")]
98 pub edge_type: String,
99 pub src: EndpointSpec,
100 pub dst: EndpointSpec,
101 #[serde(default)]
102 pub properties: Vec<String>,
103 /// Optional source-column → property-name remapping for the property
104 /// columns (the endpoint columns are renamed to `src`/`dst` for free).
105 #[serde(default)]
106 pub rename: HashMap<String, String>,
107 /// Source columns that tell **parallel** edges apart: they join the
108 /// relationship's MERGE key (`-[e:type {k: …}]->`), so two rows with the same
109 /// endpoints and different keys are two edges. Empty (the default) keeps one
110 /// edge per `(src, type, dst)`. A key column is written as a property too.
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub key: Vec<String>,
113 /// One edge of this type per source node: an upsert first deletes the
114 /// source's other outgoing edges of this type (see
115 /// [`EdgeSet::replace_outgoing`]). Unset means: yes for an `fk_edges` step
116 /// on an entity row — the table's vertex step IS the edge's source (label and
117 /// id column) — no for a junction table and for an `edge` step (their rows are
118 /// independent edges). Never with `key`.
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub replace: Option<bool>,
121}
122
123impl Transform for EdgeTransform {
124 fn apply(&self, _ctx: &TableContext, batches: &[RecordBatch]) -> Result<GraphMutation> {
125 let mut out = GraphMutation::default();
126 for batch in batches {
127 let projected = self.project_edge(batch)?;
128 out.edges.push(EdgeSet {
129 src_label: self.src.label.clone(),
130 edge_type: self.edge_type.clone(),
131 dst_label: self.dst.label.clone(),
132 src_id_column: self.src.node_id_column().to_string(),
133 dst_id_column: self.dst.node_id_column().to_string(),
134 // One relationship per endpoint pair unless `key` names the
135 // columns that tell parallel edges apart.
136 edge_key_props: self.key.iter().map(|k| renamed(k, &self.rename)).collect(),
137 replace_outgoing: self.replace.unwrap_or(false) && self.key.is_empty(),
138 batch: projected,
139 });
140 }
141 Ok(out)
142 }
143}
144
145impl EdgeTransform {
146 /// Build the edge output batch: the `src.column` becomes `src`, the
147 /// `dst.column` becomes `dst`, and the `properties` ride along (renamed).
148 ///
149 /// Done explicitly rather than via [`project`] because a **self-key** edge
150 /// (`src.column == dst.column`, e.g. a "same-as"/sibling link keyed on one
151 /// column) must emit *both* `src` and `dst` from that single input column —
152 /// a name-keyed rename map can only hold one mapping for the column, so the
153 /// generic projection would drop one endpoint. Property columns that clash
154 /// with `src`/`dst` or with each other are reported as
155 /// [`BifrostError::DuplicateOutputColumn`].
156 fn project_edge(&self, batch: &RecordBatch) -> Result<RecordBatch> {
157 let schema = batch.schema();
158 let mut fields = Vec::new();
159 let mut arrays = Vec::new();
160 let mut seen_output = std::collections::HashSet::new();
161
162 let mut push = |out_name: &str, src_col: &str| -> Result<()> {
163 if !seen_output.insert(out_name.to_string()) {
164 return Err(BifrostError::DuplicateOutputColumn(out_name.to_string()));
165 }
166 let idx = schema
167 .index_of(src_col)
168 .map_err(|_| BifrostError::ColumnNotFound(src_col.to_string()))?;
169 let field = schema.field(idx);
170 fields.push(Field::new(
171 out_name,
172 field.data_type().clone(),
173 field.is_nullable(),
174 ));
175 arrays.push(batch.column(idx).clone());
176 Ok(())
177 };
178
179 // Endpoints first — both emitted even when they share one input column.
180 push("src", &self.src.column)?;
181 push("dst", &self.dst.column)?;
182
183 // Properties (de-duplicated by source column), renamed; an output-name
184 // clash with an endpoint or another property is a hard error.
185 let mut seen_prop_source = std::collections::HashSet::new();
186 for prop in self.properties.iter().chain(&self.key) {
187 if !seen_prop_source.insert(prop.clone()) {
188 continue;
189 }
190 let out_name = renamed(prop, &self.rename);
191 push(&out_name, prop)?;
192 }
193
194 Ok(RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)?)
195 }
196}
197
198/// `fk_edges` is an `edge` whose endpoints live on other tables/steps — the
199/// mechanics are identical, the alias exists so mappings read naturally.
200pub type FkEdgesTransform = EdgeTransform;
201
202// ── changelog (CDC) routing ───────────────────────────────────────────────────
203
204/// A changelog batch split by its [`SyncMode::CHANGE_TYPE_COLUMN`] into the
205/// rows that upsert vs. the rows that delete, both with the `_change_type`
206/// column stripped so they render like ordinary table rows.
207///
208/// `INSERT` / `UPDATE_AFTER` → `upserts`; `DELETE` → `deletes`. `UPDATE_BEFORE`
209/// is dropped: it is the pre-image of an in-place update whose `UPDATE_AFTER`
210/// upsert overwrites the node, so emitting a delete for it would destroy the
211/// updated row (deletes are applied last in the sink). An unknown change type is
212/// a hard error (better than silently dropping a row).
213#[derive(Debug, Default)]
214pub struct ChangeRows {
215 /// Rows to feed the upsert pipeline (vertices + edges).
216 pub upserts: Vec<RecordBatch>,
217 /// Rows that name nodes to remove.
218 pub deletes: Vec<RecordBatch>,
219}
220
221impl ChangeRows {
222 pub fn is_empty(&self) -> bool {
223 self.upserts.is_empty() && self.deletes.is_empty()
224 }
225}
226
227/// Does this batch carry a changelog `_change_type` column?
228pub fn is_changelog_batch(batch: &RecordBatch) -> bool {
229 batch
230 .schema()
231 .index_of(SyncMode::CHANGE_TYPE_COLUMN)
232 .is_ok()
233}
234
235/// Partition changelog `batches` into upsert vs. delete rows by `_change_type`,
236/// stripping that column from the output. Batches without a `_change_type`
237/// column are treated as all-upsert (and passed through unchanged), so a
238/// non-CDC source flowing through CDC routing is a no-op.
239pub fn partition_change_rows(batches: &[RecordBatch]) -> Result<ChangeRows> {
240 let mut out = ChangeRows::default();
241 for batch in batches {
242 let Ok(ct_idx) = batch.schema().index_of(SyncMode::CHANGE_TYPE_COLUMN) else {
243 out.upserts.push(batch.clone());
244 continue;
245 };
246 let kinds = batch
247 .column(ct_idx)
248 .as_any()
249 .downcast_ref::<StringArray>()
250 .ok_or_else(|| {
251 BifrostError::Source(format!(
252 "{} must be a string column",
253 SyncMode::CHANGE_TYPE_COLUMN
254 ))
255 })?;
256
257 let mut keep_upsert = Vec::with_capacity(batch.num_rows());
258 let mut keep_delete = Vec::with_capacity(batch.num_rows());
259 for row in 0..batch.num_rows() {
260 // A null change type is ambiguous; refuse it loudly.
261 if kinds.is_null(row) {
262 return Err(BifrostError::Source(format!(
263 "null {} at row {row}",
264 SyncMode::CHANGE_TYPE_COLUMN
265 )));
266 }
267 // Parsed through the ONE shared vocabulary (`cdc::ChangeOp`), so
268 // this routes Iceberg's `INSERT`/`UPDATE_AFTER`/… and Spark 4.2's
269 // `insert`/`update_postimage`/… identically. Before that, a Spark
270 // 4.2 native changelog would have hit the `other =>` arm and failed
271 // the whole round — the two engines genuinely disagree on spelling.
272 match ChangeOp::parse(kinds.value(row)) {
273 Some(ChangeOp::Insert) | Some(ChangeOp::UpdateAfter) => {
274 keep_upsert.push(row as u32)
275 }
276 Some(ChangeOp::Delete) => keep_delete.push(row as u32),
277 // A folded `update` row (see `cdc::fold_updates`) carries the
278 // post-image, so it upserts like an `UPDATE_AFTER`.
279 Some(ChangeOp::Update) => keep_upsert.push(row as u32),
280 // UPDATE_BEFORE is the pre-image of an in-place update. Its
281 // paired UPDATE_AFTER (an upsert) fully overwrites the node via
282 // MERGE+SET, so the pre-image carries no net state change. It
283 // must NOT become a delete: deletes run last in the sink, so
284 // routing UPDATE_BEFORE to `deletes` would DETACH DELETE the
285 // node the UPDATE_AFTER just upserted — destroying an updated
286 // row. Drop it. (A true row removal arrives as DELETE.)
287 Some(ChangeOp::UpdateBefore) => {}
288 None => {
289 return Err(BifrostError::Source(format!(
290 "unknown {} '{}'",
291 SyncMode::CHANGE_TYPE_COLUMN,
292 kinds.value(row)
293 )));
294 }
295 }
296 }
297
298 let stripped = drop_column(batch, ct_idx)?;
299 if !keep_upsert.is_empty() {
300 out.upserts.push(take_rows(&stripped, &keep_upsert)?);
301 }
302 if !keep_delete.is_empty() {
303 out.deletes.push(take_rows(&stripped, &keep_delete)?);
304 }
305 }
306 Ok(out)
307}
308
309/// Run `transforms` over the delete rows and reinterpret every emitted
310/// [`VertexSet`] as a [`VertexDelete`] (same label + id column). Edge sets are
311/// dropped here: deleting a vertex DETACH-deletes its edges. An edge-only
312/// table's deletes are [`edge_deletes_from_change_rows`].
313pub fn deletes_from_change_rows(
314 transforms: &[Arc<dyn Transform>],
315 ctx: &TableContext,
316 delete_batches: &[RecordBatch],
317) -> Result<Vec<VertexDelete>> {
318 if delete_batches.is_empty() {
319 return Ok(Vec::new());
320 }
321 let mut deletes = Vec::new();
322 for transform in transforms {
323 let mutation = transform.apply_deletes(ctx, delete_batches)?;
324 for set in mutation.vertices {
325 deletes.push(VertexDelete {
326 label: set.label,
327 id_column: set.id_column,
328 batch: set.batch,
329 });
330 }
331 }
332 Ok(deletes)
333}
334
335/// Run `transforms` over the delete rows and keep every emitted [`EdgeSet`]:
336/// the relationships those rows made, to remove.
337///
338/// For every edge step, not only an edge-only table's: when a row changes its
339/// foreign key (an UPDATE is a DELETE of the old row plus an INSERT of the new
340/// one), the old row's edge points at the old endpoint and must go, while its
341/// vertex delete is suppressed by the upsert. The sink suppresses an edge
342/// delete the same way — an edge also upserted this round survives.
343pub fn edge_deletes_from_change_rows(
344 transforms: &[Arc<dyn Transform>],
345 ctx: &TableContext,
346 delete_batches: &[RecordBatch],
347) -> Result<Vec<EdgeSet>> {
348 if delete_batches.is_empty() {
349 return Ok(Vec::new());
350 }
351 let mut out = Vec::new();
352 for transform in transforms {
353 out.extend(transform.apply_deletes(ctx, delete_batches)?.edges);
354 }
355 Ok(out)
356}
357
358// ── helpers ─────────────────────────────────────────────────────────────────
359
360fn renamed(name: &str, rename: &HashMap<String, String>) -> String {
361 rename
362 .get(name)
363 .cloned()
364 .unwrap_or_else(|| name.to_string())
365}
366
367/// Return a copy of `batch` with column `idx` removed.
368fn drop_column(batch: &RecordBatch, idx: usize) -> Result<RecordBatch> {
369 let schema = batch.schema();
370 let fields: Vec<Field> = schema
371 .fields()
372 .iter()
373 .enumerate()
374 .filter(|(i, _)| *i != idx)
375 .map(|(_, f)| f.as_ref().clone())
376 .collect();
377 let columns = batch
378 .columns()
379 .iter()
380 .enumerate()
381 .filter(|(i, _)| *i != idx)
382 .map(|(_, c)| c.clone())
383 .collect();
384 Ok(RecordBatch::try_new(
385 Arc::new(Schema::new(fields)),
386 columns,
387 )?)
388}
389
390/// Select `rows` (by index) from every column of `batch`.
391fn take_rows(batch: &RecordBatch, rows: &[u32]) -> Result<RecordBatch> {
392 let indices = arrow_array::UInt32Array::from(rows.to_vec());
393 let columns = batch
394 .columns()
395 .iter()
396 .map(|c| arrow::compute::take(c.as_ref(), &indices, None))
397 .collect::<std::result::Result<Vec<_>, _>>()?;
398 Ok(RecordBatch::try_new(batch.schema(), columns)?)
399}
400
401/// Select `columns` (by name, in order) from `batch`, applying `rename`.
402///
403/// `columns` is de-duplicated by **source** name first (a column listed twice —
404/// e.g. an `id_column` that also appears in a vertex's `properties` — is taken
405/// once), then any remaining clash on the *output* name (two different source
406/// columns renamed to the same name) is reported as
407/// [`BifrostError::DuplicateOutputColumn`] rather than surfacing as an opaque
408/// Arrow duplicate-field error from `RecordBatch::try_new`.
409fn project(
410 batch: &RecordBatch,
411 columns: &[String],
412 rename: &HashMap<String, String>,
413) -> Result<RecordBatch> {
414 let schema = batch.schema();
415 let mut fields = Vec::with_capacity(columns.len());
416 let mut arrays = Vec::with_capacity(columns.len());
417 let mut seen_source = std::collections::HashSet::new();
418 let mut seen_output = std::collections::HashSet::new();
419 for name in columns {
420 // Same source column requested twice → keep the first, skip the rest.
421 if !seen_source.insert(name.clone()) {
422 continue;
423 }
424 let out_name = renamed(name, rename);
425 if !seen_output.insert(out_name.clone()) {
426 return Err(BifrostError::DuplicateOutputColumn(out_name));
427 }
428 let idx = schema
429 .index_of(name)
430 .map_err(|_| BifrostError::ColumnNotFound(name.clone()))?;
431 let field = schema.field(idx);
432 fields.push(Field::new(
433 out_name,
434 field.data_type().clone(),
435 field.is_nullable(),
436 ));
437 arrays.push(batch.column(idx).clone());
438 }
439 Ok(RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays)?)
440}