Skip to main content

knut_bifrost/
mapping.rs

1//! Declarative YAML mapping spec — the fabric's blueprint.
2
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5
6use crate::{
7    error::{BifrostError, Result},
8    registry::TransformRegistry,
9    transform::{EdgeTransform, Transform, VertexTransform},
10};
11
12/// Top-level mapping file.
13///
14/// ```yaml
15/// version: 1
16/// tables:
17///   - table: lake.social.users
18///     mode: incremental
19///     pipeline:
20///       - vertex: { label: Person, id_column: user_id, properties: [name] }
21///       - fk_edges:
22///           type: WORKS_AT
23///           src: { label: Person,  column: user_id }
24///           dst: { label: Company, column: company_id }
25///           properties: [since]          # edge property columns
26///           rename: { since: joined_at } # optional column → prop renaming
27/// ```
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct MappingSpec {
30    pub version: u32,
31    pub tables: Vec<TableMapping>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct TableMapping {
36    /// Fully qualified table name as Spark sees it.
37    pub table: String,
38    #[serde(default)]
39    pub mode: SyncMode,
40    pub pipeline: Vec<PipelineStep>,
41    /// **Projection override.** The columns the source is allowed to read. Empty
42    /// (the default) means *derive it from the pipeline* — see
43    /// [`projected_columns`](TableMapping::projected_columns) — which is what you
44    /// want almost always; set it only to read a column the pipeline does not
45    /// name (a custom transform's input, say).
46    ///
47    /// ```yaml
48    /// columns: [id, name, country]
49    /// ```
50    #[serde(default)]
51    pub columns: Vec<String>,
52    /// **Predicate pushdown.** An optional row filter pushed into the Iceberg
53    /// scan planner, so partitions / data files / row groups that cannot match
54    /// are never opened.
55    ///
56    /// ```yaml
57    /// filter: { column: country, eq: SE }
58    /// ```
59    #[serde(default)]
60    pub filter: Option<RowFilter>,
61}
62
63/// A literal in a [`RowFilter`]. Untagged, so YAML writes the natural scalar
64/// (`eq: SE`, `eq: 42`, `eq: true`) with no type tag.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(untagged)]
67pub enum FilterValue {
68    Bool(bool),
69    Int(i64),
70    Float(f64),
71    Str(String),
72}
73
74/// A **backend-neutral row filter** on one column — the YAML face of the scan
75/// predicate the Iceberg source pushes down.
76///
77/// Backend-neutral on purpose: `bifrost`'s core carries no engine dependency (the
78/// `skade` source is an optional feature), so the spec type lives here and is
79/// lowered to `skade::ScanFilter` only inside the Iceberg source. Exactly ONE of
80/// `eq` / `in` / (`ge`,`le`) must be set.
81///
82/// ```yaml
83/// filter: { column: country, eq: SE }            # equality (partition pruning)
84/// filter: { column: country, in: [SE, NO, DK] }  # set membership
85/// filter: { column: year, ge: 2020, le: 2026 }   # inclusive range
86/// ```
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct RowFilter {
89    /// The column the predicate is on.
90    pub column: String,
91    /// `column == value`.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub eq: Option<FilterValue>,
94    /// `column IN (values…)`.
95    #[serde(rename = "in", default, skip_serializing_if = "Option::is_none")]
96    pub is_in: Option<Vec<FilterValue>>,
97    /// Inclusive lower bound (`value <= column`).
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub ge: Option<FilterValue>,
100    /// Inclusive upper bound (`column <= value`).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub le: Option<FilterValue>,
103}
104
105impl RowFilter {
106    /// `column == value`.
107    pub fn eq(column: impl Into<String>, value: impl Into<FilterValue>) -> Self {
108        Self {
109            column: column.into(),
110            eq: Some(value.into()),
111            is_in: None,
112            ge: None,
113            le: None,
114        }
115    }
116
117    /// `column IN (values…)`.
118    pub fn is_in<V: Into<FilterValue>>(
119        column: impl Into<String>,
120        values: impl IntoIterator<Item = V>,
121    ) -> Self {
122        Self {
123            column: column.into(),
124            eq: None,
125            is_in: Some(values.into_iter().map(Into::into).collect()),
126            ge: None,
127            le: None,
128        }
129    }
130
131    /// Inclusive range `lo <= column <= hi`; either bound may be `None`.
132    pub fn range(
133        column: impl Into<String>,
134        lo: Option<impl Into<FilterValue>>,
135        hi: Option<impl Into<FilterValue>>,
136    ) -> Self {
137        Self {
138            column: column.into(),
139            eq: None,
140            is_in: None,
141            ge: lo.map(Into::into),
142            le: hi.map(Into::into),
143        }
144    }
145
146    /// Reject a filter that names no form (or more than one) — a mapping that
147    /// says `filter: { column: x }` silently matching everything would be a
148    /// pushdown that quietly does nothing.
149    pub fn validate(&self) -> Result<()> {
150        let forms = usize::from(self.eq.is_some())
151            + usize::from(self.is_in.is_some())
152            + usize::from(self.ge.is_some() || self.le.is_some());
153        match forms {
154            1 => Ok(()),
155            0 => Err(BifrostError::Other(format!(
156                "filter on `{}` sets none of `eq` / `in` / `ge`+`le` — it would prune nothing",
157                self.column
158            ))),
159            _ => Err(BifrostError::Other(format!(
160                "filter on `{}` sets more than one of `eq` / `in` / `ge`+`le`",
161                self.column
162            ))),
163        }
164    }
165}
166
167impl From<&str> for FilterValue {
168    fn from(s: &str) -> Self {
169        FilterValue::Str(s.to_string())
170    }
171}
172impl From<String> for FilterValue {
173    fn from(s: String) -> Self {
174        FilterValue::Str(s)
175    }
176}
177impl From<i64> for FilterValue {
178    fn from(v: i64) -> Self {
179        FilterValue::Int(v)
180    }
181}
182impl From<f64> for FilterValue {
183    fn from(v: f64) -> Self {
184        FilterValue::Float(v)
185    }
186}
187impl From<bool> for FilterValue {
188    fn from(v: bool) -> Self {
189        FilterValue::Bool(v)
190    }
191}
192
193#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum SyncMode {
196    /// Re-read the whole table when its snapshot changes (idempotent MERGE).
197    #[default]
198    Full,
199    /// Read only what changed since the last processed snapshot. Over Spark
200    /// Connect it is an `EXCEPT ALL` diff — append-only, inserts alone. Over an
201    /// Iceberg table read by skade (`watch`) it is the same per-commit changelog
202    /// as [`SyncMode::Cdc`]: updates and deletes are applied too.
203    Incremental,
204    /// Changelog (CDC) mode: read Iceberg's `system.create_changelog_view`
205    /// between the last and the current snapshot, exposing a `_change_type`
206    /// column (`INSERT` / `UPDATE_AFTER` / `UPDATE_BEFORE` / `DELETE`).
207    /// `INSERT`/`UPDATE_AFTER` rows become upserts; `DELETE` rows become
208    /// `DETACH DELETE`s. `UPDATE_BEFORE` (the update pre-image) is dropped — its
209    /// `UPDATE_AFTER` upsert already carries the new state. The first round (no
210    /// prior snapshot) reads the whole table as inserts, like `Full`.
211    Cdc,
212}
213
214impl SyncMode {
215    /// The sentinel column the Iceberg changelog view adds in [`Cdc`] mode,
216    /// and that the listener partitions on before stripping it from values.
217    ///
218    /// [`Cdc`]: SyncMode::Cdc
219    pub const CHANGE_TYPE_COLUMN: &'static str = "_change_type";
220}
221
222/// One `- vertex: …` / `- edge: …` / `- fk_edges: …` / `- custom: name`
223/// entry. Untagged so the YAML reads as a single-key map per step.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(untagged)]
226pub enum PipelineStep {
227    Vertex {
228        vertex: VertexTransform,
229    },
230    Edge {
231        edge: EdgeTransform,
232    },
233    /// Same shape as `edge`; named for FK-derived relationships.
234    FkEdges {
235        fk_edges: EdgeTransform,
236    },
237    /// Reference to a transform registered in [`TransformRegistry`].
238    Custom {
239        custom: String,
240    },
241    /// A SQL `SELECT` over the table's rows whose result names what it becomes
242    /// (`_label` + `_id` → nodes, `_type` + `_from_label`/`_from` +
243    /// `_to_label`/`_to` → edges). See [`crate::sql`]. Feature `sql`.
244    Sql {
245        sql: String,
246    },
247}
248
249impl MappingSpec {
250    pub fn from_yaml(yaml: &str) -> Result<Self> {
251        Ok(serde_yaml::from_str(yaml)?)
252    }
253
254    pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self> {
255        Self::from_yaml(&std::fs::read_to_string(path)?)
256    }
257
258    /// Parse a [`MappingSpec`] from **RON** — the constellation's
259    /// RON-as-description form, sibling to [`from_yaml`](Self::from_yaml).
260    ///
261    /// RON parity closes the seam the findings doc named: the Iceberg→graph
262    /// mapping is now a first-class RON object like every other spec, and RON is
263    /// what [`from_arrow_schema`](Self::from_arrow_schema) emits. YAML stays
264    /// accepted — this is additive.
265    pub fn from_ron(ron: &str) -> Result<Self> {
266        Ok(ron::from_str(ron)?)
267    }
268
269    /// Render this spec to **RON** text (pretty, stable field order via the
270    /// struct derive) — the round-trip partner of [`from_ron`](Self::from_ron).
271    pub fn to_ron(&self) -> Result<String> {
272        Ok(ron::ser::to_string_pretty(
273            self,
274            ron::ser::PrettyConfig::default(),
275        )?)
276    }
277
278    /// Load a [`MappingSpec`] from a `.ron` file.
279    pub fn load_ron(path: impl AsRef<std::path::Path>) -> Result<Self> {
280        Self::from_ron(&std::fs::read_to_string(path)?)
281    }
282
283    /// **Derive a *candidate* `MappingSpec` from an Iceberg table's Arrow
284    /// schema** — the schema→mapping codegen half of the RON seam.
285    ///
286    /// The generator emits ONE vertex per table, a best-guess id column, the
287    /// remaining scalar columns as vertex properties, and one `fk_edges`
288    /// candidate per foreign-key-shaped (`*_id`) column. It is meant to be
289    /// emitted as RON, reviewed, and corrected by a human — it is a *starting
290    /// point*, not an oracle.
291    ///
292    /// **Honest by construction (the findings-doc law): no column is ever
293    /// silently dropped.** Every input field is accounted for exactly once —
294    /// mapped as the id, a property, or an FK edge endpoint, or else SURFACED in
295    /// [`SchemaMappingCandidate::unmapped`] (a nested/complex type that cannot
296    /// become a scalar graph property). Every *guessed* role (the id column,
297    /// each FK edge) is recorded in [`SchemaMappingCandidate::guesses`] so a
298    /// reviewer knows precisely what to check.
299    pub fn from_arrow_schema(table: &str, schema: &arrow_schema::Schema) -> SchemaMappingCandidate {
300        let label = label_for_table(table);
301        let mut unmapped: Vec<UnmappedColumn> = Vec::new();
302        let mut guesses: Vec<String> = Vec::new();
303
304        // Partition scalar vs non-scalar columns. Non-scalar (nested) columns
305        // cannot be a scalar graph property → surfaced, never dropped.
306        let mut scalar_cols: Vec<String> = Vec::new();
307        for f in schema.fields() {
308            if is_scalar(f.data_type()) {
309                scalar_cols.push(f.name().clone());
310            } else {
311                unmapped.push(UnmappedColumn {
312                    name: f.name().clone(),
313                    arrow_type: format!("{}", f.data_type()),
314                    reason: nested_reason(f.data_type()),
315                });
316            }
317        }
318
319        // Pick the id column: exact `id`, else `<table_singular>_id`, else the
320        // first `*_id`, else the first scalar column. Always a GUESS.
321        let table_fk = format!("{}_id", singularize(&last_segment(table)));
322        let id_column = scalar_cols
323            .iter()
324            .find(|c| c.as_str() == "id")
325            .or_else(|| scalar_cols.iter().find(|c| **c == table_fk))
326            .or_else(|| scalar_cols.iter().find(|c| c.ends_with("_id")))
327            .or_else(|| scalar_cols.first())
328            .cloned();
329
330        let Some(id_column) = id_column else {
331            // A table with only nested columns: nothing to key a vertex on. Emit
332            // an empty-pipeline candidate; everything is in `unmapped`.
333            guesses.push(format!(
334                "no scalar column to use as `{label}` id — every column is nested (see `unmapped`)"
335            ));
336            return SchemaMappingCandidate {
337                spec: MappingSpec {
338                    version: 1,
339                    tables: vec![TableMapping {
340                        table: table.to_string(),
341                        mode: SyncMode::default(),
342                        pipeline: Vec::new(),
343                        columns: Vec::new(),
344                        filter: None,
345                    }],
346                },
347                unmapped,
348                guesses,
349            };
350        };
351        guesses.push(format!("id column for `{label}` guessed as `{id_column}`"));
352
353        // FK-shaped columns (other `*_id`) → edge candidates.
354        let fk_cols: Vec<String> = scalar_cols
355            .iter()
356            .filter(|c| **c != id_column && c.ends_with("_id"))
357            .cloned()
358            .collect();
359
360        // Remaining scalar columns become vertex properties (id + FKs excluded).
361        let properties: Vec<String> = scalar_cols
362            .iter()
363            .filter(|c| **c != id_column && !fk_cols.contains(c))
364            .cloned()
365            .collect();
366
367        let mut pipeline = vec![PipelineStep::Vertex {
368            vertex: VertexTransform {
369                label: label.clone(),
370                id_column: id_column.clone(),
371                properties,
372                rename: Default::default(),
373            },
374        }];
375
376        for fk in &fk_cols {
377            let dst_label = label_for_fk(fk);
378            let edge_type = format!("HAS_{}", fk.trim_end_matches("_id").to_uppercase());
379            guesses.push(format!(
380                "edge `{edge_type}` guessed from FK column `{fk}` → `{dst_label}` \
381                 (verify the target label + endpoint id column)"
382            ));
383            pipeline.push(PipelineStep::FkEdges {
384                fk_edges: EdgeTransform {
385                    edge_type,
386                    src: EndpointSpec {
387                        label: label.clone(),
388                        column: id_column.clone(),
389                        id_column: None,
390                    },
391                    dst: EndpointSpec {
392                        label: dst_label,
393                        column: fk.clone(),
394                        id_column: None,
395                    },
396                    properties: Vec::new(),
397                    rename: Default::default(),
398                    key: Vec::new(),
399                    replace: None,
400                },
401            });
402        }
403
404        SchemaMappingCandidate {
405            spec: MappingSpec {
406                version: 1,
407                tables: vec![TableMapping {
408                    table: table.to_string(),
409                    mode: SyncMode::default(),
410                    pipeline,
411                    columns: Vec::new(),
412                    filter: None,
413                }],
414            },
415            unmapped,
416            guesses,
417        }
418    }
419}
420
421// ═══════════════════════════════════════════════════════════════════════════
422// Schema → candidate mapping (codegen)
423// ═══════════════════════════════════════════════════════════════════════════
424
425use crate::transform::EndpointSpec;
426
427/// The outcome of [`MappingSpec::from_arrow_schema`] — a candidate mapping plus
428/// the honest accounting of what the generator guessed and what it could not map.
429///
430/// Honest by construction: `unmapped` + every field the `spec` references cover
431/// **every** input column. A reviewer reads `guesses` to see what to check and
432/// `unmapped` to see what to add by hand; nothing was silently dropped.
433#[derive(Debug, Clone)]
434pub struct SchemaMappingCandidate {
435    /// The generated candidate mapping — emit it with [`MappingSpec::to_ron`].
436    pub spec: MappingSpec,
437    /// Columns the generator could not map to a scalar graph property (nested /
438    /// complex Arrow types). Surfaced for a human, **never dropped**.
439    pub unmapped: Vec<UnmappedColumn>,
440    /// Every role the generator GUESSED (the id column, each FK edge) — the
441    /// review checklist.
442    pub guesses: Vec<String>,
443}
444
445impl SchemaMappingCandidate {
446    /// `true` when the generator mapped every column with no unmapped residue
447    /// (there may still be [`guesses`](Self::guesses) to review).
448    pub fn is_fully_mapped(&self) -> bool {
449        self.unmapped.is_empty()
450    }
451
452    /// Render the candidate spec to RON (convenience for `spec.to_ron()`).
453    pub fn to_ron(&self) -> Result<String> {
454        self.spec.to_ron()
455    }
456}
457
458/// One column [`MappingSpec::from_arrow_schema`] could not confidently map —
459/// surfaced, not dropped.
460#[derive(Debug, Clone, PartialEq)]
461pub struct UnmappedColumn {
462    /// The column name.
463    pub name: String,
464    /// Its Arrow type, rendered.
465    pub arrow_type: String,
466    /// Why it could not be mapped to a scalar graph property.
467    pub reason: String,
468}
469
470/// `true` for Arrow types that can be a scalar graph property.
471fn is_scalar(dt: &arrow_schema::DataType) -> bool {
472    use arrow_schema::DataType as D;
473    matches!(
474        dt,
475        D::Boolean
476            | D::Int8
477            | D::Int16
478            | D::Int32
479            | D::Int64
480            | D::UInt8
481            | D::UInt16
482            | D::UInt32
483            | D::UInt64
484            | D::Float16
485            | D::Float32
486            | D::Float64
487            | D::Utf8
488            | D::LargeUtf8
489            | D::Utf8View
490            | D::Date32
491            | D::Date64
492            | D::Timestamp(_, _)
493            | D::Decimal128(_, _)
494            | D::Decimal256(_, _)
495    )
496}
497
498/// Why a non-scalar column can't be a graph property (for the `unmapped` note).
499fn nested_reason(dt: &arrow_schema::DataType) -> String {
500    use arrow_schema::DataType as D;
501    let kind = match dt {
502        D::Struct(_) => "a struct",
503        D::List(_)
504        | D::LargeList(_)
505        | D::FixedSizeList(_, _)
506        | D::ListView(_)
507        | D::LargeListView(_) => "a list",
508        D::Map(_, _) => "a map",
509        D::Union(_, _) => "a union",
510        _ => "a non-scalar",
511    };
512    format!(
513        "{kind} type is not a scalar graph property — map it by hand (flatten, or a custom transform)"
514    )
515}
516
517/// The last dotted segment of a qualified table name (`lake.social.users` → `users`).
518fn last_segment(table: &str) -> String {
519    table.rsplit('.').next().unwrap_or(table).to_string()
520}
521
522/// A crude English singularizer good enough for a *candidate* label
523/// (`users` → `user`, `companies` → `company`). A human corrects it.
524fn singularize(word: &str) -> String {
525    if let Some(stem) = word.strip_suffix("ies") {
526        format!("{stem}y")
527    } else if word.ends_with("ss") {
528        word.to_string()
529    } else if let Some(stem) = word.strip_suffix('s') {
530        stem.to_string()
531    } else {
532        word.to_string()
533    }
534}
535
536/// PascalCase a snake/plain identifier for a node label.
537fn pascal_case(word: &str) -> String {
538    word.split('_')
539        .filter(|s| !s.is_empty())
540        .map(|s| {
541            let mut c = s.chars();
542            match c.next() {
543                Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
544                None => String::new(),
545            }
546        })
547        .collect()
548}
549
550/// The candidate node label for a table (`lake.social.users` → `User`).
551fn label_for_table(table: &str) -> String {
552    pascal_case(&singularize(&last_segment(table)))
553}
554
555/// The candidate destination label for an FK column (`company_id` → `Company`).
556fn label_for_fk(fk: &str) -> String {
557    pascal_case(fk.trim_end_matches("_id"))
558}
559
560impl TableMapping {
561    /// **The columns this mapping actually touches** — the projection a source
562    /// may prune to.
563    ///
564    /// Every declarative step names its inputs exactly: a `vertex` reads its
565    /// `id_column` plus its `properties`; an `edge`/`fk_edges` reads its two
566    /// endpoint columns plus its `properties`. A mapping that builds a `Person`
567    /// node out of `(id, name)` therefore needs 2 of the table's columns — and
568    /// until the projection was wired, the Iceberg source read **all of them**
569    /// on every snapshot change.
570    ///
571    /// Returns `None` when the projection would be UNSOUND:
572    /// * a `custom` step is an opaque registry transform that may read any
573    ///   column, so pruning could starve it;
574    /// * an explicit [`columns`](TableMapping::columns) override is absent AND
575    ///   the derived set is empty (nothing to prune to).
576    ///
577    /// `None` means "read every column", i.e. the previous behaviour — the
578    /// projection is a strict optimisation that can only ever narrow a read it
579    /// is provably safe to narrow.
580    pub fn projected_columns(&self) -> Option<Vec<String>> {
581        // An explicit override wins — the operator has taken responsibility.
582        if !self.columns.is_empty() {
583            return Some(dedup_preserving_order(self.columns.iter().cloned()));
584        }
585        let mut cols: Vec<String> = Vec::new();
586        for step in &self.pipeline {
587            match step {
588                PipelineStep::Vertex { vertex } => {
589                    cols.push(vertex.id_column.clone());
590                    cols.extend(vertex.properties.iter().cloned());
591                }
592                PipelineStep::Edge { edge: t } | PipelineStep::FkEdges { fk_edges: t } => {
593                    cols.push(t.src.column.clone());
594                    cols.push(t.dst.column.clone());
595                    cols.extend(t.properties.iter().cloned());
596                    cols.extend(t.key.iter().cloned());
597                }
598                // Opaque: bail out of projecting entirely. A SQL step could name
599                // its columns, but reading them out of an expression is the
600                // engine's job, not this one's.
601                PipelineStep::Custom { .. } | PipelineStep::Sql { .. } => return None,
602            }
603        }
604        let cols = dedup_preserving_order(cols);
605        if cols.is_empty() { None } else { Some(cols) }
606    }
607
608    /// Resolve the pipeline into executable transforms.
609    pub fn compile(&self, registry: &TransformRegistry) -> Result<Vec<Arc<dyn Transform>>> {
610        if self.pipeline.is_empty() {
611            return Err(BifrostError::EmptyPipeline(self.table.clone()));
612        }
613        self.pipeline
614            .iter()
615            .map(|step| -> Result<Arc<dyn Transform>> {
616                Ok(match step {
617                    PipelineStep::Vertex { vertex } => Arc::new(vertex.clone()),
618                    PipelineStep::Edge { edge: t } => Arc::new(t.clone()),
619                    // A foreign key on an ENTITY row is one edge per source node
620                    // — replace — but only when the row IS its source node: the
621                    // table's own vertex step has the edge source's label and id
622                    // column. A junction table (no such vertex; many rows per
623                    // source) keeps every edge.
624                    PipelineStep::FkEdges { fk_edges: t } => {
625                        let entity_row = self.pipeline.iter().any(|s| {
626                            matches!(s, PipelineStep::Vertex { vertex } if vertex.label == t.src.label && vertex.id_column == t.src.column)
627                        });
628                        Arc::new(EdgeTransform { replace: Some(t.replace.unwrap_or(entity_row)), ..t.clone() })
629                    }
630                    PipelineStep::Custom { custom } => registry.get(custom)?,
631                    #[cfg(feature = "sql")]
632                    PipelineStep::Sql { sql } => Arc::new(crate::sql::SqlTransform::new(sql)?),
633                    #[cfg(not(feature = "sql"))]
634                    PipelineStep::Sql { .. } => {
635                        return Err(BifrostError::Other(format!(
636                            "table `{}` has a `sql:` step and this build has no SQL engine - rebuild with --features sql",
637                            self.table
638                        )));
639                    }
640                })
641            })
642            .collect()
643    }
644}
645
646/// De-duplicate while preserving first-seen order (the projection must be stable
647/// so a source's column list — and any bench measuring it — is deterministic).
648fn dedup_preserving_order(items: impl IntoIterator<Item = String>) -> Vec<String> {
649    let mut seen = std::collections::HashSet::new();
650    items
651        .into_iter()
652        .filter(|c| seen.insert(c.clone()))
653        .collect()
654}
655
656// ═══════════════════════════════════════════════════════════════════════════
657// The RESIDUAL row filter — the same predicate, applied in memory
658// ═══════════════════════════════════════════════════════════════════════════
659
660/// Apply a [`RowFilter`] to `batches` in memory, keeping only matching rows.
661///
662/// **Why this exists.** A `filter` on a [`TableMapping`] is a row PREDICATE, not a
663/// hint: nothing downstream re-applies it (the [`Listener`](crate::Listener) hands
664/// it to the source and never drops a row itself), so a source that cannot push it
665/// down must still honour it or the operator's exclusions land in the graph anyway.
666///
667/// The Iceberg source pushes the predicate into the scan planner for
668/// [`SyncMode::Full`] — file/row-group pruning, which is strictly faster. For every
669/// other route (the manifest-level delta scan of `Incremental`/`Cdc`, and any
670/// source with no pruning surface at all) this is the fallback that keeps the
671/// MEANING identical: same predicate, same rows, only a different cost.
672///
673/// Pure and engine-free (arrow compute only), so it lives beside the spec type
674/// rather than inside the optional `skade` source.
675///
676/// A filter whose column is absent from the batch schema is an error, not a
677/// silently-empty or silently-full result — an operator who names a column that
678/// does not exist has a broken mapping and must be told.
679pub fn apply_row_filter(
680    batches: &[arrow_array::RecordBatch],
681    filter: &RowFilter,
682) -> Result<Vec<arrow_array::RecordBatch>> {
683    apply_row_filter_with(batches, filter, FilterNulls::Drop)
684}
685
686/// **What to do with a row the predicate cannot judge** — one whose filter
687/// column is NULL.
688///
689/// The two answers are both right, for different data, and getting them the
690/// wrong way round costs correctness rather than speed:
691///
692/// * [`FilterNulls::Drop`] — a NULL does not satisfy `country = 'SE'`, so the
693///   row goes. This is what the Iceberg scan planner does, so it is what an
694///   ordinary (`Full` / `Incremental`) read must do, or the same mapping would
695///   select different rows depending on the mode.
696/// * [`FilterNulls::Keep`] — the NULL means *unknown*, not *absent*. A CDC
697///   `DELETE` row is widened to the table schema with **nulls in every
698///   non-identity column**, so a `Drop` predicate over a changelog batch
699///   silently removes deletions — the one CDC failure that must never happen
700///   quietly. Changelog reads therefore keep what they cannot judge.
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum FilterNulls {
703    /// A NULL filter column fails the predicate — the row is dropped.
704    /// Scan-planner semantics; the default.
705    Drop,
706    /// A NULL filter column is *unjudgeable* — the row is kept. Changelog
707    /// semantics (a widened `DELETE` row's columns are all null).
708    Keep,
709}
710
711/// [`apply_row_filter`] with an explicit [`FilterNulls`] policy.
712///
713/// One predicate implementation, two null policies — rather than a second copy
714/// of the kernel lowering living in the CDC module (L5).
715pub fn apply_row_filter_with(
716    batches: &[arrow_array::RecordBatch],
717    filter: &RowFilter,
718    nulls: FilterNulls,
719) -> Result<Vec<arrow_array::RecordBatch>> {
720    filter.validate()?;
721    batches
722        .iter()
723        .map(|b| filter_batch(b, filter, nulls))
724        .collect()
725}
726
727/// One batch through the residual predicate.
728fn filter_batch(
729    batch: &arrow_array::RecordBatch,
730    filter: &RowFilter,
731    nulls: FilterNulls,
732) -> Result<arrow_array::RecordBatch> {
733    use arrow::compute::kernels::{boolean, cmp};
734
735    let idx = batch.schema().index_of(&filter.column).map_err(|_| {
736        BifrostError::Other(format!(
737            "filter column `{}` is not in the batch schema ({:?})",
738            filter.column,
739            batch
740                .schema()
741                .fields()
742                .iter()
743                .map(|f| f.name().clone())
744                .collect::<Vec<_>>()
745        ))
746    })?;
747    let col = batch.column(idx).clone();
748
749    let mask = if let Some(v) = &filter.eq {
750        cmp::eq(&col, &scalar_datum(v, &col)?).map_err(arrow_err)?
751    } else if let Some(vs) = &filter.is_in {
752        // An empty `IN ()` matches nothing — the same answer the scan planner
753        // gives when the predicate prunes every file.
754        let mut acc: Option<arrow_array::BooleanArray> = None;
755        for v in vs {
756            let m = cmp::eq(&col, &scalar_datum(v, &col)?).map_err(arrow_err)?;
757            acc = Some(match acc {
758                None => m,
759                Some(a) => boolean::or(&a, &m).map_err(arrow_err)?,
760            });
761        }
762        acc.unwrap_or_else(|| arrow_array::BooleanArray::from(vec![false; batch.num_rows()]))
763    } else {
764        // Inclusive range; either bound may be absent (`validate` already
765        // guaranteed at least one is present).
766        let mut acc: Option<arrow_array::BooleanArray> = None;
767        if let Some(lo) = &filter.ge {
768            acc = Some(cmp::gt_eq(&col, &scalar_datum(lo, &col)?).map_err(arrow_err)?);
769        }
770        if let Some(hi) = &filter.le {
771            let m = cmp::lt_eq(&col, &scalar_datum(hi, &col)?).map_err(arrow_err)?;
772            acc = Some(match acc {
773                None => m,
774                Some(a) => boolean::and(&a, &m).map_err(arrow_err)?,
775            });
776        }
777        acc.expect("validate() guarantees a range filter sets ge and/or le")
778    };
779
780    // `filter_record_batch` treats a NULL mask entry as FALSE, i.e. it drops the
781    // row. That is right for `Drop` and catastrophic for `Keep`, so `Keep`
782    // resolves the nulls to TRUE first instead of relying on that default.
783    let mask = match nulls {
784        FilterNulls::Drop => mask,
785        FilterNulls::Keep => {
786            use arrow_array::Array;
787            (0..batch.num_rows())
788                .map(|r| Some(col.is_null(r) || (mask.is_valid(r) && mask.value(r))))
789                .collect::<arrow_array::BooleanArray>()
790        }
791    };
792    arrow::compute::filter_record_batch(batch, &mask).map_err(arrow_err)
793}
794
795/// Lower one [`FilterValue`] to a single-element arrow `Datum` typed to MATCH the
796/// column it is compared against. Comparing across types is refused rather than
797/// coerced: a silent coercion is how a predicate starts selecting the wrong rows.
798fn scalar_datum(
799    v: &FilterValue,
800    col: &arrow_array::ArrayRef,
801) -> Result<arrow_array::Scalar<arrow_array::ArrayRef>> {
802    use arrow_array::{
803        ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, Scalar, StringArray,
804    };
805    use arrow_schema::DataType as D;
806
807    let dt = col.data_type();
808    let arr: ArrayRef = match (v, dt) {
809        (FilterValue::Str(s), D::Utf8 | D::LargeUtf8) => {
810            Arc::new(StringArray::from(vec![s.clone()]))
811        }
812        (FilterValue::Int(i), D::Int64) => Arc::new(Int64Array::from(vec![*i])),
813        (FilterValue::Int(i), D::Int32) => Arc::new(Int32Array::from(vec![*i as i32])),
814        (FilterValue::Float(f), D::Float64) => Arc::new(Float64Array::from(vec![*f])),
815        // An integer literal against a float column is unambiguous and lossless
816        // for the ranges a mapping writes; anything else is a type error.
817        (FilterValue::Int(i), D::Float64) => Arc::new(Float64Array::from(vec![*i as f64])),
818        (FilterValue::Bool(b), D::Boolean) => Arc::new(BooleanArray::from(vec![*b])),
819        _ => {
820            return Err(BifrostError::Other(format!(
821                "filter literal {v:?} is not comparable with column type {dt:?} — \
822                 refusing to coerce (a coerced predicate selects the wrong rows)"
823            )));
824        }
825    };
826    Ok(Scalar::new(arr))
827}
828
829fn arrow_err(e: arrow_schema::ArrowError) -> BifrostError {
830    BifrostError::Other(format!("residual row filter: {e}"))
831}
832
833// ═══════════════════════════════════════════════════════════════════════════
834// Tests — RON parity + schema→mapping codegen (the closed RON seam)
835// ═══════════════════════════════════════════════════════════════════════════
836
837// ── mapping formats ─────────────────────────────────────────────────────────
838
839/// A **mapping format**: whatever it is written in, it compiles to the one
840/// [`MappingSpec`] the listener runs — so the changelog path, the cursors and
841/// the sinks are the same whichever format a mapping came from.
842///
843/// | format | file | impl |
844/// |---|---|---|
845/// | bifröst's own: per table, a pipeline of steps | `.yml` / `.yaml` / `.ron` | [`MappingSpec`] |
846/// | PuppyGraph's `schema.json`: graph-first, `node` + `edge` lists | `.json` | [`PuppyGraphMapping`] |
847/// | SQL: one `SELECT` per step, reserved columns say what a row becomes | `.sql` | [`SqlMapping`] (feature `sql`) |
848pub trait GraphMapping {
849    fn compile(&self) -> Result<MappingSpec>;
850}
851
852impl GraphMapping for MappingSpec {
853    fn compile(&self) -> Result<MappingSpec> {
854        Ok(self.clone())
855    }
856}
857
858/// A PuppyGraph `schema.json`; it names no sync mode, so `mode` is every table's.
859/// See [`crate::puppygraph`].
860#[derive(Debug, Clone)]
861pub struct PuppyGraphMapping {
862    pub json: String,
863    pub mode: SyncMode,
864}
865
866impl GraphMapping for PuppyGraphMapping {
867    fn compile(&self) -> Result<MappingSpec> {
868        crate::puppygraph::mapping_from_puppygraph(&self.json, self.mode)
869    }
870}
871
872/// A `.sql` mapping: `;`-separated `SELECT`s, each a step of the table its
873/// `FROM` names, every table in `mode`. See [`crate::sql`].
874#[derive(Debug, Clone)]
875pub struct SqlMapping {
876    pub sql: String,
877    pub mode: SyncMode,
878}
879
880impl GraphMapping for SqlMapping {
881    #[cfg(feature = "sql")]
882    fn compile(&self) -> Result<MappingSpec> {
883        crate::sql::mapping_from_sql(&self.sql, self.mode)
884    }
885
886    #[cfg(not(feature = "sql"))]
887    fn compile(&self) -> Result<MappingSpec> {
888        Err(BifrostError::Other("a .sql mapping needs the SQL engine - rebuild with --features sql".into()))
889    }
890}
891
892/// Load a mapping file in whichever format its extension names; `mode` is the
893/// sync mode of the formats that carry none (PuppyGraph, SQL). bifröst's own
894/// format names a mode per table and ignores it.
895pub fn load_mapping(path: impl AsRef<std::path::Path>, mode: SyncMode) -> Result<MappingSpec> {
896    let path = path.as_ref();
897    let text = std::fs::read_to_string(path)?;
898    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
899    match ext.as_str() {
900        "json" => PuppyGraphMapping { json: text, mode }.compile(),
901        "sql" => SqlMapping { sql: text, mode }.compile(),
902        "ron" => MappingSpec::from_ron(&text),
903        _ => MappingSpec::from_yaml(&text),
904    }
905}
906
907#[cfg(test)]
908mod ron_seam_tests {
909    use super::*;
910    use arrow_schema::{DataType, Field, Fields, Schema, TimeUnit};
911
912    /// A hand-authored YAML mapping still parses, then round-trips through RON
913    /// losslessly — YAML and RON are two faces of the SAME `MappingSpec`.
914    #[test]
915    fn ron_round_trips_and_matches_yaml() {
916        let yaml = r#"
917version: 1
918tables:
919  - table: lake.social.users
920    mode: incremental
921    pipeline:
922      - vertex: { label: Person, id_column: user_id, properties: [name] }
923      - fk_edges:
924          type: WORKS_AT
925          src: { label: Person,  column: user_id }
926          dst: { label: Company, column: company_id }
927          properties: [since]
928          rename: { since: joined_at }
929"#;
930        let from_yaml = MappingSpec::from_yaml(yaml).expect("yaml parses");
931
932        // Render to RON and parse it straight back — no field lost.
933        let ron = from_yaml.to_ron().expect("to_ron");
934        let from_ron = MappingSpec::from_ron(&ron).expect("from_ron parses our own RON");
935
936        // Structural equality across the whole spec (MappingSpec isn't Eq, so
937        // compare the RON renders — a stable, total serialization).
938        assert_eq!(
939            from_ron.to_ron().unwrap(),
940            from_yaml.to_ron().unwrap(),
941            "YAML→RON→spec equals the YAML spec"
942        );
943        // And a spot-check on the parsed content.
944        assert_eq!(from_ron.tables.len(), 1);
945        assert_eq!(from_ron.tables[0].table, "lake.social.users");
946        assert!(matches!(from_ron.tables[0].mode, SyncMode::Incremental));
947        assert_eq!(from_ron.tables[0].pipeline.len(), 2);
948    }
949
950    /// A candidate mapping is derived from an Arrow schema: one vertex keyed on
951    /// the guessed id, scalar columns as properties, an FK column as an edge —
952    /// and it round-trips through RON.
953    #[test]
954    fn schema_infers_candidate_mapping_and_emits_ron() {
955        let schema = Schema::new(vec![
956            Field::new("user_id", DataType::Int64, false),
957            Field::new("name", DataType::Utf8, true),
958            Field::new("age", DataType::Int32, true),
959            Field::new("company_id", DataType::Int64, true), // FK
960            Field::new(
961                "created_at",
962                DataType::Timestamp(TimeUnit::Microsecond, None),
963                true,
964            ),
965        ]);
966        let cand = MappingSpec::from_arrow_schema("lake.social.users", &schema);
967
968        assert!(
969            cand.is_fully_mapped(),
970            "all scalar columns mapped, none unmapped"
971        );
972        let tm = &cand.spec.tables[0];
973        assert_eq!(tm.table, "lake.social.users");
974
975        // Vertex: label singularized+PascalCased from the table, id guessed as
976        // `user_id`, properties = the non-id non-FK scalar columns.
977        let PipelineStep::Vertex { vertex } = &tm.pipeline[0] else {
978            panic!("first step is the vertex");
979        };
980        assert_eq!(vertex.label, "User");
981        assert_eq!(vertex.id_column, "user_id");
982        assert_eq!(vertex.properties, vec!["name", "age", "created_at"]);
983
984        // FK edge from `company_id` → Company.
985        let PipelineStep::FkEdges { fk_edges } = &tm.pipeline[1] else {
986            panic!("second step is the FK edge");
987        };
988        assert_eq!(fk_edges.edge_type, "HAS_COMPANY");
989        assert_eq!(fk_edges.src.label, "User");
990        assert_eq!(fk_edges.dst.label, "Company");
991        assert_eq!(fk_edges.dst.column, "company_id");
992
993        // The guesses checklist names the id + the edge.
994        assert!(cand.guesses.iter().any(|g| g.contains("id column")));
995        assert!(cand.guesses.iter().any(|g| g.contains("HAS_COMPANY")));
996
997        // The candidate emits as RON and re-parses (it's a real MappingSpec).
998        let ron = cand.to_ron().expect("candidate → RON");
999        let reparsed = MappingSpec::from_ron(&ron).expect("candidate RON re-parses");
1000        assert_eq!(reparsed.tables[0].pipeline.len(), 2);
1001    }
1002
1003    /// **Honest-by-construction: a nested column is SURFACED, never dropped.** A
1004    /// struct column can't be a scalar graph property, so it lands in `unmapped`
1005    /// with its type + reason — not silently omitted from the mapping.
1006    #[test]
1007    fn nested_column_is_surfaced_not_dropped() {
1008        let addr = Fields::from(vec![
1009            Field::new("city", DataType::Utf8, true),
1010            Field::new("zip", DataType::Utf8, true),
1011        ]);
1012        let schema = Schema::new(vec![
1013            Field::new("id", DataType::Int64, false),
1014            Field::new("name", DataType::Utf8, true),
1015            Field::new("address", DataType::Struct(addr), true), // nested!
1016            Field::new(
1017                "tags",
1018                DataType::List(Field::new("item", DataType::Utf8, true).into()),
1019                true,
1020            ),
1021        ]);
1022        let cand = MappingSpec::from_arrow_schema("lake.crm.accounts", &schema);
1023
1024        assert!(!cand.is_fully_mapped(), "there are unmapped nested columns");
1025        let names: Vec<&str> = cand.unmapped.iter().map(|u| u.name.as_str()).collect();
1026        assert!(
1027            names.contains(&"address"),
1028            "the struct column is surfaced: {names:?}"
1029        );
1030        assert!(
1031            names.contains(&"tags"),
1032            "the list column is surfaced: {names:?}"
1033        );
1034
1035        // Every input column is accounted for: id + property + the two unmapped.
1036        let PipelineStep::Vertex { vertex } = &cand.spec.tables[0].pipeline[0] else {
1037            panic!("vertex step");
1038        };
1039        let mut accounted: Vec<String> = vec![vertex.id_column.clone()];
1040        accounted.extend(vertex.properties.iter().cloned());
1041        accounted.extend(cand.unmapped.iter().map(|u| u.name.clone()));
1042        accounted.sort();
1043        assert_eq!(
1044            accounted,
1045            vec!["address", "id", "name", "tags"],
1046            "no column silently dropped — mapped or surfaced, every one"
1047        );
1048        // The reason is human-actionable.
1049        assert!(cand.unmapped.iter().any(|u| u.reason.contains("struct")));
1050    }
1051
1052    /// Whether the compiled edge step of `yaml`'s first table replaces.
1053    fn replaces(yaml: &str) -> bool {
1054        use arrow_array::{Int64Array, RecordBatch};
1055        use arrow_schema::{DataType, Field, Schema};
1056        let spec = MappingSpec::from_yaml(yaml).unwrap();
1057        let steps = spec.tables[0].compile(&TransformRegistry::new()).unwrap();
1058        let batch = RecordBatch::try_new(
1059            Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false), Field::new("b", DataType::Int64, false)])),
1060            vec![Arc::new(Int64Array::from(vec![1])), Arc::new(Int64Array::from(vec![2]))],
1061        )
1062        .unwrap();
1063        let ctx = crate::transform::TableContext { table: "t".into() };
1064        steps.iter().flat_map(|s| s.apply(&ctx, std::slice::from_ref(&batch)).unwrap().edges).any(|e| e.replace_outgoing)
1065    }
1066
1067    #[test]
1068    fn fk_edges_replace_only_on_an_entity_row() {
1069        let entity = "version: 1\ntables:\n  - table: t\n    pipeline:\n      - vertex: { label: A, id_column: a }\n      \
1070                      - fk_edges: { type: T, src: { label: A, column: a }, dst: { label: B, column: b } }\n";
1071        assert!(replaces(entity), "the row is its source node: one edge per source");
1072        let junction = "version: 1\ntables:\n  - table: t\n    pipeline:\n      \
1073                        - fk_edges: { type: T, src: { label: A, column: a }, dst: { label: B, column: b } }\n";
1074        assert!(!replaces(junction), "a junction table keeps every edge");
1075        let opted_out = entity.replace("dst: { label: B, column: b } }", "dst: { label: B, column: b }, replace: false }");
1076        assert!(!replaces(&opted_out));
1077        let edge_opt_in = junction.replace("fk_edges:", "edge:").replace("dst: { label: B, column: b } }", "dst: { label: B, column: b }, replace: true }");
1078        assert!(replaces(&edge_opt_in), "an edge step may opt in");
1079    }
1080}