Skip to main content

laminar_connectors/connector/
source_batch.rs

1//! Source-batch metadata encoding, validation, and ergonomic views.
2
3use std::sync::Arc;
4
5use arrow_array::{
6    Array, ArrayRef, BinaryArray, RecordBatch, RecordBatchOptions, UInt32Array, UInt8Array,
7};
8use arrow_schema::{DataType, Field, Schema, SchemaRef};
9
10use crate::checkpoint::{SourceCheckpoint, SourceCheckpointDelta};
11use crate::error::ConnectorError;
12
13use super::SourceRowPositionCapability;
14
15/// Reserved column carrying the source partition bytes.
16pub const SOURCE_PARTITION_COLUMN: &str = "__source_partition";
17/// Reserved column carrying an order-preserving source cursor.
18pub const SOURCE_ORDER_KEY_COLUMN: &str = "__source_order_key";
19/// Reserved column carrying a row ordinal within one source cursor.
20pub const SOURCE_SUB_OFFSET_COLUMN: &str = "__source_sub_offset";
21/// Reserved column carrying row-level source mutations for stateful operators.
22#[doc(hidden)]
23pub const SOURCE_MUTATION_COLUMN: &str = "__source_mutation";
24
25const SOURCE_POSITION_COLUMNS: [&str; 3] = [
26    SOURCE_PARTITION_COLUMN,
27    SOURCE_ORDER_KEY_COLUMN,
28    SOURCE_SUB_OFFSET_COLUMN,
29];
30const SOURCE_METADATA_COLUMNS: [&str; 4] = [
31    SOURCE_MUTATION_COLUMN,
32    SOURCE_PARTITION_COLUMN,
33    SOURCE_ORDER_KEY_COLUMN,
34    SOURCE_SUB_OFFSET_COLUMN,
35];
36
37/// Append the reserved row-position fields to a connector's declared schema.
38///
39/// # Errors
40/// Returns an error when the declared schema already uses a reserved field name.
41pub fn schema_with_source_row_positions(schema: &SchemaRef) -> Result<SchemaRef, ConnectorError> {
42    schema_with_source_metadata(schema, false)
43}
44
45/// Append the mixed-mutation field and trailing row-position fields to a declared schema.
46///
47/// # Errors
48/// Returns an error when the declared schema already uses a reserved field name.
49pub fn schema_with_source_mutations_and_row_positions(
50    schema: &SchemaRef,
51) -> Result<SchemaRef, ConnectorError> {
52    schema_with_source_metadata(schema, true)
53}
54
55fn schema_with_source_metadata(
56    schema: &SchemaRef,
57    include_mutations: bool,
58) -> Result<SchemaRef, ConnectorError> {
59    if let Some(field) = schema.fields().iter().find(|field| {
60        SOURCE_METADATA_COLUMNS
61            .iter()
62            .any(|reserved| field.name().eq_ignore_ascii_case(reserved))
63    }) {
64        return Err(ConnectorError::SchemaMismatch(format!(
65            "source schema contains reserved metadata column '{}'",
66            field.name()
67        )));
68    }
69
70    let mut fields = schema.fields().to_vec();
71    if include_mutations {
72        fields.push(Arc::new(Field::new(
73            SOURCE_MUTATION_COLUMN,
74            DataType::UInt8,
75            false,
76        )));
77    }
78    fields.extend([
79        Arc::new(Field::new(SOURCE_PARTITION_COLUMN, DataType::Binary, false)),
80        Arc::new(Field::new(SOURCE_ORDER_KEY_COLUMN, DataType::Binary, false)),
81        Arc::new(Field::new(
82            SOURCE_SUB_OFFSET_COLUMN,
83            DataType::UInt32,
84            false,
85        )),
86    ]);
87    Ok(Arc::new(Schema::new_with_metadata(
88        fields,
89        schema.metadata().clone(),
90    )))
91}
92
93#[derive(Clone, Copy)]
94struct SourceMetadataLayout {
95    visible_columns: usize,
96    has_mutations: bool,
97}
98
99fn source_metadata_layout(schema: &Schema) -> Result<Option<SourceMetadataLayout>, ConnectorError> {
100    let fields = schema.fields();
101    if !fields.iter().any(|field| {
102        SOURCE_METADATA_COLUMNS
103            .iter()
104            .any(|reserved| field.name().eq_ignore_ascii_case(reserved))
105    }) {
106        return Ok(None);
107    }
108
109    let position_start = fields
110        .len()
111        .checked_sub(SOURCE_POSITION_COLUMNS.len())
112        .ok_or_else(|| {
113            ConnectorError::SchemaMismatch(
114                "source metadata must end with the exact three row-position fields".into(),
115            )
116        })?;
117    let expected_positions = [
118        (SOURCE_PARTITION_COLUMN, DataType::Binary),
119        (SOURCE_ORDER_KEY_COLUMN, DataType::Binary),
120        (SOURCE_SUB_OFFSET_COLUMN, DataType::UInt32),
121    ];
122    for (field, (name, data_type)) in fields[position_start..].iter().zip(expected_positions) {
123        if field.name() != name || field.data_type() != &data_type || field.is_nullable() {
124            return Err(ConnectorError::SchemaMismatch(
125                "source metadata must end with the exact three typed row-position fields".into(),
126            ));
127        }
128    }
129
130    let has_mutations = position_start != 0
131        && fields[position_start - 1]
132            .name()
133            .eq_ignore_ascii_case(SOURCE_MUTATION_COLUMN);
134    let visible_columns = position_start - usize::from(has_mutations);
135    if has_mutations {
136        let field = &fields[visible_columns];
137        if field.name() != SOURCE_MUTATION_COLUMN
138            || field.data_type() != &DataType::UInt8
139            || field.is_nullable()
140        {
141            return Err(ConnectorError::SchemaMismatch(
142                "source mutation metadata must be the exact non-null UInt8 field immediately before row positions"
143                    .into(),
144            ));
145        }
146    }
147    if fields[..visible_columns].iter().any(|field| {
148        SOURCE_METADATA_COLUMNS
149            .iter()
150            .any(|reserved| field.name().eq_ignore_ascii_case(reserved))
151    }) {
152        return Err(ConnectorError::SchemaMismatch(
153            "source metadata fields are reserved and must use their exact trailing positions"
154                .into(),
155        ));
156    }
157
158    Ok(Some(SourceMetadataLayout {
159        visible_columns,
160        has_mutations,
161    }))
162}
163
164fn validate_source_position_arrays(
165    records: &RecordBatch,
166    layout: SourceMetadataLayout,
167) -> Result<(), ConnectorError> {
168    let position_start = layout.visible_columns + usize::from(layout.has_mutations);
169    let partition = records.columns()[position_start]
170        .as_any()
171        .downcast_ref::<BinaryArray>();
172    let order = records.columns()[position_start + 1]
173        .as_any()
174        .downcast_ref::<BinaryArray>();
175    let sub_offset = records.columns()[position_start + 2]
176        .as_any()
177        .downcast_ref::<UInt32Array>();
178    let (Some(partition), Some(order), Some(sub_offset)) = (partition, order, sub_offset) else {
179        return Err(ConnectorError::SchemaMismatch(
180            "source row-position metadata arrays have invalid types".into(),
181        ));
182    };
183    if partition.len() != records.num_rows()
184        || order.len() != records.num_rows()
185        || sub_offset.len() != records.num_rows()
186    {
187        return Err(ConnectorError::SchemaMismatch(
188            "source row-position metadata is not row-aligned".into(),
189        ));
190    }
191    if partition.null_count() != 0 || order.null_count() != 0 || sub_offset.null_count() != 0 {
192        return Err(ConnectorError::SchemaMismatch(
193            "source row-position metadata must not contain nulls".into(),
194        ));
195    }
196    Ok(())
197}
198
199/// One borrowed deterministic source coordinate.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub struct SourceRowPositionRef<'a> {
202    /// Connector-defined partition identity.
203    pub partition: &'a [u8],
204    /// Connector-defined order-preserving cursor.
205    pub order_key: &'a [u8],
206    /// Row ordinal within one source cursor.
207    pub sub_offset: u32,
208}
209
210/// Validated borrowed access to row-aligned deterministic source coordinates.
211#[derive(Debug, Clone, Copy)]
212pub struct SourceRowPositionView<'a> {
213    partition: &'a BinaryArray,
214    order_key: &'a BinaryArray,
215    sub_offset: &'a UInt32Array,
216}
217
218impl<'a> SourceRowPositionView<'a> {
219    /// Number of row positions.
220    #[must_use]
221    pub fn len(self) -> usize {
222        self.partition.len()
223    }
224
225    /// Whether the batch has no row positions.
226    #[must_use]
227    pub fn is_empty(self) -> bool {
228        self.partition.is_empty()
229    }
230
231    /// Position for `row`, if it is in bounds.
232    #[must_use]
233    pub fn get(self, row: usize) -> Option<SourceRowPositionRef<'a>> {
234        (row < self.len()).then(|| SourceRowPositionRef {
235            partition: self.partition.value(row),
236            order_key: self.order_key.value(row),
237            sub_offset: self.sub_offset.value(row),
238        })
239    }
240}
241
242/// Borrow optional row-aligned source positions after validating their canonical layout.
243///
244/// # Errors
245/// Returns an error for partial, misplaced, nullable, incorrectly typed, or misaligned metadata.
246pub fn source_row_positions(
247    records: &RecordBatch,
248) -> Result<Option<SourceRowPositionView<'_>>, ConnectorError> {
249    let Some(layout) = source_metadata_layout(records.schema().as_ref())? else {
250        return Ok(None);
251    };
252    validate_source_position_arrays(records, layout)?;
253    let position_start = layout.visible_columns + usize::from(layout.has_mutations);
254    let (Some(partition), Some(order_key), Some(sub_offset)) = (
255        records.columns()[position_start]
256            .as_any()
257            .downcast_ref::<BinaryArray>(),
258        records.columns()[position_start + 1]
259            .as_any()
260            .downcast_ref::<BinaryArray>(),
261        records.columns()[position_start + 2]
262            .as_any()
263            .downcast_ref::<UInt32Array>(),
264    ) else {
265        return Err(ConnectorError::SchemaMismatch(
266            "source row-position metadata arrays have invalid types".into(),
267        ));
268    };
269    Ok(Some(SourceRowPositionView {
270        partition,
271        order_key,
272        sub_offset,
273    }))
274}
275
276fn validate_encoded_source_schema(
277    visible: &Schema,
278    encoded: &Schema,
279    expect_mutations: bool,
280) -> Result<(), ConnectorError> {
281    let layout = source_metadata_layout(encoded)?.ok_or_else(|| {
282        ConnectorError::SchemaMismatch("encoded source schema is missing row positions".into())
283    })?;
284    if layout.has_mutations != expect_mutations
285        || encoded.fields()[..layout.visible_columns] != visible.fields()[..]
286        || encoded.metadata() != visible.metadata()
287    {
288        return Err(ConnectorError::SchemaMismatch(
289            "encoded source schema does not match its visible schema and metadata contract".into(),
290        ));
291    }
292    Ok(())
293}
294
295/// Validated borrowed access to row-aligned source mutations.
296#[derive(Debug, Clone, Copy)]
297pub struct SourceMutationView<'a> {
298    values: &'a UInt8Array,
299}
300
301impl SourceMutationView<'_> {
302    /// Number of row mutations.
303    #[must_use]
304    pub fn len(self) -> usize {
305        self.values.len()
306    }
307
308    /// Whether the batch has no row mutations.
309    #[must_use]
310    pub fn is_empty(self) -> bool {
311        self.values.is_empty()
312    }
313
314    /// Mutation for `row`, if it is in bounds.
315    #[must_use]
316    pub fn get(self, row: usize) -> Option<SourceMutation> {
317        self.values.values().get(row).map(|value| match *value {
318            0 => SourceMutation::Put,
319            1 => SourceMutation::Tombstone,
320            _ => unreachable!("SourceMutationView is validated when constructed"),
321        })
322    }
323}
324
325/// Borrow the optional row-aligned mutation metadata after validating it.
326///
327/// # Errors
328/// Returns an error for malformed metadata, nulls, unknown values, or a noncanonical all-put
329/// mutation column.
330pub fn source_mutations(
331    records: &RecordBatch,
332) -> Result<Option<SourceMutationView<'_>>, ConnectorError> {
333    source_mutations_validated(records, false)
334}
335
336/// Borrow mutations from a slice derived from a strictly validated routed batch.
337///
338/// Unlike ingress validation, this permits a retained mutation column whose slice contains only
339/// puts. Layout, alignment, types, nulls, and values remain validated.
340///
341/// # Errors
342/// Returns an error for malformed mutation or row-position metadata.
343pub fn source_mutations_routed(
344    records: &RecordBatch,
345) -> Result<Option<SourceMutationView<'_>>, ConnectorError> {
346    source_mutations_validated(records, true)
347}
348
349fn source_mutations_validated(
350    records: &RecordBatch,
351    allow_all_put: bool,
352) -> Result<Option<SourceMutationView<'_>>, ConnectorError> {
353    let Some(layout) = source_metadata_layout(records.schema().as_ref())? else {
354        return Ok(None);
355    };
356    validate_source_position_arrays(records, layout)?;
357    if !layout.has_mutations {
358        return Ok(None);
359    }
360    let mutations = records
361        .column(layout.visible_columns)
362        .as_any()
363        .downcast_ref::<UInt8Array>()
364        .ok_or_else(|| {
365            ConnectorError::SchemaMismatch("source mutation metadata array is not UInt8".into())
366        })?;
367    if mutations.len() != records.num_rows() {
368        return Err(ConnectorError::SchemaMismatch(format!(
369            "source mutation count {} does not match decoded row count {}",
370            mutations.len(),
371            records.num_rows()
372        )));
373    }
374    if mutations.null_count() != 0 {
375        return Err(ConnectorError::SchemaMismatch(
376            "source mutation metadata must not contain nulls".into(),
377        ));
378    }
379    let mut has_tombstone = false;
380    for &value in mutations.values() {
381        match value {
382            0 => {}
383            1 => has_tombstone = true,
384            value => {
385                return Err(ConnectorError::SchemaMismatch(format!(
386                    "source mutation metadata contains unknown value {value}"
387                )));
388            }
389        }
390    }
391    if !has_tombstone && !allow_all_put {
392        return Err(ConnectorError::SchemaMismatch(
393            "all-put source mutations must omit the mutation metadata field".into(),
394        ));
395    }
396    Ok(Some(SourceMutationView { values: mutations }))
397}
398
399/// Remove only mutation metadata while retaining the exact trailing row positions.
400///
401/// # Errors
402/// Returns an error when any source metadata field is malformed.
403pub fn strip_source_mutations(records: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
404    strip_source_mutations_validated(records, false)
405}
406
407/// Remove mutation metadata from a slice derived from a strictly validated routed batch.
408///
409/// # Errors
410/// Returns an error when any source metadata field is malformed.
411pub fn strip_source_mutations_routed(records: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
412    strip_source_mutations_validated(records, true)
413}
414
415fn strip_source_mutations_validated(
416    records: &RecordBatch,
417    allow_all_put: bool,
418) -> Result<RecordBatch, ConnectorError> {
419    let schema = records.schema();
420    let Some(layout) = source_metadata_layout(schema.as_ref())? else {
421        return Ok(records.clone());
422    };
423    source_mutations_validated(records, allow_all_put)?;
424    if !layout.has_mutations {
425        return Ok(records.clone());
426    }
427    let mut fields = schema.fields()[..layout.visible_columns].to_vec();
428    fields.extend(
429        schema.fields()[layout.visible_columns + 1..]
430            .iter()
431            .cloned(),
432    );
433    let mut columns = records.columns()[..layout.visible_columns].to_vec();
434    columns.extend(
435        records.columns()[layout.visible_columns + 1..]
436            .iter()
437            .cloned(),
438    );
439    let options = RecordBatchOptions::new().with_row_count(Some(records.num_rows()));
440    RecordBatch::try_new_with_options(
441        Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone())),
442        columns,
443        &options,
444    )
445    .map_err(|error| {
446        ConnectorError::SchemaMismatch(format!("failed to strip source mutation metadata: {error}"))
447    })
448}
449
450/// Remove all connector metadata without copying visible Arrow buffers.
451///
452/// Batches without reserved fields are returned unchanged. Mutation metadata, when present, must
453/// be immediately before the exact trailing row-position fields.
454///
455/// # Errors
456/// Returns an error for partial, misplaced, nullable, incorrectly typed, or invalid metadata.
457pub fn strip_source_row_positions(records: &RecordBatch) -> Result<RecordBatch, ConnectorError> {
458    let schema = records.schema();
459    let Some(layout) = source_metadata_layout(schema.as_ref())? else {
460        return Ok(records.clone());
461    };
462    source_mutations(records)?;
463    let visible_schema = Arc::new(Schema::new_with_metadata(
464        schema.fields()[..layout.visible_columns].to_vec(),
465        schema.metadata().clone(),
466    ));
467    let options = RecordBatchOptions::new().with_row_count(Some(records.num_rows()));
468    RecordBatch::try_new_with_options(
469        visible_schema,
470        records.columns()[..layout.visible_columns].to_vec(),
471        &options,
472    )
473    .map_err(|error| {
474        ConnectorError::SchemaMismatch(format!("failed to strip source metadata: {error}"))
475    })
476}
477
478/// Deterministic source coordinates aligned one-for-one with decoded rows.
479#[derive(Debug, Clone)]
480pub struct SourceRowPositions {
481    partition: BinaryArray,
482    order_key: BinaryArray,
483    sub_offset: UInt32Array,
484}
485
486impl SourceRowPositions {
487    /// Construct a validated row-position sidecar.
488    ///
489    /// # Errors
490    /// Returns an error when arrays differ in length or contain nulls.
491    pub fn try_new(
492        partition: BinaryArray,
493        order_key: BinaryArray,
494        sub_offset: UInt32Array,
495    ) -> Result<Self, ConnectorError> {
496        let len = partition.len();
497        if order_key.len() != len || sub_offset.len() != len {
498            return Err(ConnectorError::SchemaMismatch(format!(
499                "source row-position arrays have different lengths: partition={len}, order={}, sub_offset={}",
500                order_key.len(),
501                sub_offset.len()
502            )));
503        }
504        if partition.null_count() != 0
505            || order_key.null_count() != 0
506            || sub_offset.null_count() != 0
507        {
508            return Err(ConnectorError::SchemaMismatch(
509                "source row-position arrays must not contain nulls".into(),
510            ));
511        }
512        Ok(Self {
513            partition,
514            order_key,
515            sub_offset,
516        })
517    }
518
519    fn len(&self) -> usize {
520        self.partition.len()
521    }
522
523    /// Partition coordinate for each row.
524    #[must_use]
525    pub const fn partition(&self) -> &BinaryArray {
526        &self.partition
527    }
528
529    /// Order-preserving cursor for each row.
530    #[must_use]
531    pub const fn order_key(&self) -> &BinaryArray {
532        &self.order_key
533    }
534
535    /// Row ordinal within one source cursor.
536    #[must_use]
537    pub const fn sub_offset(&self) -> &UInt32Array {
538        &self.sub_offset
539    }
540
541    fn validate_row_count(&self, rows: usize) -> Result<(), ConnectorError> {
542        if self.len() == rows {
543            Ok(())
544        } else {
545            Err(ConnectorError::SchemaMismatch(format!(
546                "source row-position count {} does not match decoded row count {rows}",
547                self.len()
548            )))
549        }
550    }
551
552    fn append_metadata(
553        self,
554        records: &RecordBatch,
555        encoded_schema: &SchemaRef,
556        mutations: Option<&[SourceMutation]>,
557    ) -> Result<RecordBatch, ConnectorError> {
558        self.validate_row_count(records.num_rows())?;
559        validate_encoded_source_schema(
560            records.schema().as_ref(),
561            encoded_schema.as_ref(),
562            mutations.is_some(),
563        )?;
564        if let Some(mutations) = mutations {
565            if mutations.len() != records.num_rows() {
566                return Err(ConnectorError::SchemaMismatch(format!(
567                    "source mutation count {} does not match decoded row count {}",
568                    mutations.len(),
569                    records.num_rows()
570                )));
571            }
572            if !mutations.contains(&SourceMutation::Tombstone) {
573                return Err(ConnectorError::SchemaMismatch(
574                    "all-put source mutations must omit the mutation metadata field".into(),
575                ));
576            }
577        }
578        let mut columns = records.columns().to_vec();
579        if let Some(mutations) = mutations {
580            columns.push(Arc::new(UInt8Array::from_iter_values(
581                mutations.iter().map(|mutation| match mutation {
582                    SourceMutation::Put => 0,
583                    SourceMutation::Tombstone => 1,
584                }),
585            )));
586        }
587        columns.extend([
588            Arc::new(self.partition) as ArrayRef,
589            Arc::new(self.order_key) as ArrayRef,
590            Arc::new(self.sub_offset) as ArrayRef,
591        ]);
592        RecordBatch::try_new(Arc::clone(encoded_schema), columns).map_err(|error| {
593            ConnectorError::SchemaMismatch(format!("failed to append source metadata: {error}"))
594        })
595    }
596}
597
598/// Canonical mutation applied by a stateful operator for one source row.
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub enum SourceMutation {
601    /// Insert or replace the keyed value.
602    Put,
603    /// Remove the keyed value.
604    Tombstone,
605}
606
607/// A batch of records read from a source connector.
608#[derive(Debug, Clone)]
609pub struct SourceBatch {
610    /// Arrow batch carrying the records.
611    pub records: RecordBatch,
612    row_positions: Option<SourceRowPositions>,
613    mutations: Option<Box<[SourceMutation]>>,
614    cursor: Option<SourceBatchCursor>,
615}
616
617/// Source progress captured with one emitted batch.
618#[derive(Debug, Clone)]
619pub enum SourceBatchCursor {
620    /// Complete recovery cursor captured with the batch.
621    Complete(SourceCheckpoint),
622    /// Changed offsets extending a complete cursor emitted earlier.
623    Incremental(SourceCheckpointDelta),
624}
625
626impl SourceBatch {
627    /// Construct a source batch.
628    #[must_use]
629    pub fn new(records: RecordBatch) -> Self {
630        Self {
631            records,
632            row_positions: None,
633            mutations: None,
634            cursor: None,
635        }
636    }
637
638    /// Construct a batch with deterministic positions for every decoded row.
639    ///
640    /// # Errors
641    /// Returns an error when the sidecar is not row-aligned with `records`.
642    pub fn positioned(
643        records: RecordBatch,
644        row_positions: SourceRowPositions,
645    ) -> Result<Self, ConnectorError> {
646        row_positions.validate_row_count(records.num_rows())?;
647        Ok(Self {
648            records,
649            row_positions: Some(row_positions),
650            mutations: None,
651            cursor: None,
652        })
653    }
654
655    /// Attach row-aligned mutations to a batch.
656    ///
657    /// All-`Put` input is canonicalized to the default. Connectors should call this only for
658    /// batches that may contain tombstones.
659    ///
660    /// # Errors
661    /// Returns an error when the mutation count differs from the decoded row count.
662    pub fn with_mutations(
663        mut self,
664        mutations: impl Into<Box<[SourceMutation]>>,
665    ) -> Result<Self, ConnectorError> {
666        let mutations = mutations.into();
667        if mutations.len() != self.records.num_rows() {
668            return Err(ConnectorError::SchemaMismatch(format!(
669                "source mutation count {} does not match decoded row count {}",
670                mutations.len(),
671                self.records.num_rows()
672            )));
673        }
674        self.mutations = mutations
675            .contains(&SourceMutation::Tombstone)
676            .then_some(mutations);
677        Ok(self)
678    }
679
680    /// Bind the exact source cursor captured with this batch.
681    #[must_use]
682    pub fn with_checkpoint(mut self, checkpoint: SourceCheckpoint) -> Self {
683        self.cursor = Some(SourceBatchCursor::Complete(checkpoint));
684        self
685    }
686
687    /// Bind changed offsets from the assignment checkpoint already published by an earlier batch.
688    #[must_use]
689    pub fn with_checkpoint_delta(mut self, delta: SourceCheckpointDelta) -> Self {
690        self.cursor = Some(SourceBatchCursor::Incremental(delta));
691        self
692    }
693
694    /// Remove the source cursor bound to this batch, when present.
695    pub fn take_cursor(&mut self) -> Option<SourceBatchCursor> {
696        self.cursor.take()
697    }
698
699    /// Deterministic row positions, when supplied by the connector.
700    #[must_use]
701    pub const fn row_positions(&self) -> Option<&SourceRowPositions> {
702        self.row_positions.as_ref()
703    }
704
705    /// Mixed row mutations, or `None` when every row is a [`SourceMutation::Put`].
706    #[must_use]
707    pub fn mutations(&self) -> Option<&[SourceMutation]> {
708        self.mutations.as_deref()
709    }
710
711    /// Validate and append optional mixed mutations plus deterministic row positions.
712    ///
713    /// The mutation field is omitted for the canonical all-`Put` case.
714    ///
715    /// # Errors
716    /// Returns an error when capability, schema, or row-aligned metadata is malformed.
717    pub fn into_records_with_metadata(
718        self,
719        capability: SourceRowPositionCapability,
720        positioned_schema: &SchemaRef,
721        mutation_schema: &SchemaRef,
722    ) -> Result<RecordBatch, ConnectorError> {
723        let Self {
724            records,
725            row_positions,
726            mutations,
727            cursor: _,
728        } = self;
729        match (capability, row_positions) {
730            (SourceRowPositionCapability::Unavailable, None) if mutations.is_none() => Ok(records),
731            (SourceRowPositionCapability::Unavailable, _) => Err(ConnectorError::SchemaMismatch(
732                "source emitted state metadata without declaring deterministic row positions"
733                    .into(),
734            )),
735            (SourceRowPositionCapability::OrderedDeterministic, None) => {
736                Err(ConnectorError::SchemaMismatch(
737                    "source declared deterministic row positions but omitted the sidecar".into(),
738                ))
739            }
740            (SourceRowPositionCapability::OrderedDeterministic, Some(positions)) => {
741                let schema = if mutations.is_some() {
742                    mutation_schema
743                } else {
744                    positioned_schema
745                };
746                positions.append_metadata(&records, schema, mutations.as_deref())
747            }
748        }
749    }
750
751    /// Record count in the batch.
752    #[must_use]
753    pub fn num_rows(&self) -> usize {
754        self.records.num_rows()
755    }
756}