1use 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#[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 pub table: String,
38 #[serde(default)]
39 pub mode: SyncMode,
40 pub pipeline: Vec<PipelineStep>,
41 #[serde(default)]
51 pub columns: Vec<String>,
52 #[serde(default)]
60 pub filter: Option<RowFilter>,
61}
62
63#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct RowFilter {
89 pub column: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub eq: Option<FilterValue>,
94 #[serde(rename = "in", default, skip_serializing_if = "Option::is_none")]
96 pub is_in: Option<Vec<FilterValue>>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub ge: Option<FilterValue>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub le: Option<FilterValue>,
103}
104
105impl RowFilter {
106 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 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 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 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 #[default]
198 Full,
199 Incremental,
204 Cdc,
212}
213
214impl SyncMode {
215 pub const CHANGE_TYPE_COLUMN: &'static str = "_change_type";
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(untagged)]
226pub enum PipelineStep {
227 Vertex {
228 vertex: VertexTransform,
229 },
230 Edge {
231 edge: EdgeTransform,
232 },
233 FkEdges {
235 fk_edges: EdgeTransform,
236 },
237 Custom {
239 custom: String,
240 },
241 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 pub fn from_ron(ron: &str) -> Result<Self> {
266 Ok(ron::from_str(ron)?)
267 }
268
269 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 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 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 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 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 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 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 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
421use crate::transform::EndpointSpec;
426
427#[derive(Debug, Clone)]
434pub struct SchemaMappingCandidate {
435 pub spec: MappingSpec,
437 pub unmapped: Vec<UnmappedColumn>,
440 pub guesses: Vec<String>,
443}
444
445impl SchemaMappingCandidate {
446 pub fn is_fully_mapped(&self) -> bool {
449 self.unmapped.is_empty()
450 }
451
452 pub fn to_ron(&self) -> Result<String> {
454 self.spec.to_ron()
455 }
456}
457
458#[derive(Debug, Clone, PartialEq)]
461pub struct UnmappedColumn {
462 pub name: String,
464 pub arrow_type: String,
466 pub reason: String,
468}
469
470fn 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
498fn 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
517fn last_segment(table: &str) -> String {
519 table.rsplit('.').next().unwrap_or(table).to_string()
520}
521
522fn 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
536fn 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
550fn label_for_table(table: &str) -> String {
552 pascal_case(&singularize(&last_segment(table)))
553}
554
555fn label_for_fk(fk: &str) -> String {
557 pascal_case(fk.trim_end_matches("_id"))
558}
559
560impl TableMapping {
561 pub fn projected_columns(&self) -> Option<Vec<String>> {
581 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 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 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 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
646fn 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
656pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum FilterNulls {
703 Drop,
706 Keep,
709}
710
711pub 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
727fn 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 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 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 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
795fn 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 (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
833pub 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#[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#[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
892pub 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 #[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 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 assert_eq!(
939 from_ron.to_ron().unwrap(),
940 from_yaml.to_ron().unwrap(),
941 "YAML→RON→spec equals the YAML spec"
942 );
943 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 #[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), 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 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 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 assert!(cand.guesses.iter().any(|g| g.contains("id column")));
995 assert!(cand.guesses.iter().any(|g| g.contains("HAS_COMPANY")));
996
997 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 #[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), 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 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 assert!(cand.unmapped.iter().any(|u| u.reason.contains("struct")));
1050 }
1051
1052 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}