Skip to main content

lance_table/transaction/
proto.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Conversions between the transaction types and their protobuf encoding.
5//!
6//! A transaction is persisted as a `pb::Transaction` alongside the manifest it
7//! produced, so these conversions are the format contract for everything in this
8//! module: a field added to an `Operation` is only durable once it round-trips
9//! here.
10
11use crate::format::key_existence::KeyExistenceFilter;
12use crate::format::pb;
13use crate::format::{BasePath, Fragment, IndexFile, IndexMetadata, overlay::DataOverlayFile};
14use crate::system_index::mem_wal::CompactedSsTable;
15use crate::transaction::{
16    DataOverlayGroup, DataReplacementGroup, Operation, RewriteGroup, RewrittenIndex, Transaction,
17    UpdateMap, UpdateMapEntry, UpdateMode, UpdatedFragmentOffsets, translate_config_updates,
18    translate_schema_metadata_updates,
19};
20use lance_core::datatypes::Schema;
21use lance_core::{Error, Result};
22use lance_file::datatypes::Fields;
23use roaring::RoaringBitmap;
24use std::collections::HashMap;
25use std::sync::Arc;
26use uuid::Uuid;
27
28impl From<&DataReplacementGroup> for pb::transaction::DataReplacementGroup {
29    fn from(DataReplacementGroup(fragment_id, new_file): &DataReplacementGroup) -> Self {
30        Self {
31            fragment_id: *fragment_id,
32            new_file: Some(new_file.into()),
33        }
34    }
35}
36
37/// Convert a protobug DataReplacementGroup to a rust native DataReplacementGroup
38/// this is unfortunately TryFrom instead of From because of the Option in the pb::DataReplacementGroup
39impl TryFrom<pb::transaction::DataReplacementGroup> for DataReplacementGroup {
40    type Error = Error;
41
42    fn try_from(message: pb::transaction::DataReplacementGroup) -> Result<Self> {
43        Ok(Self(
44            message.fragment_id,
45            message
46                .new_file
47                .ok_or(Error::invalid_input(
48                    "DataReplacementGroup must have a new_file",
49                ))?
50                .try_into()?,
51        ))
52    }
53}
54
55impl From<&DataOverlayGroup> for pb::transaction::DataOverlayGroup {
56    fn from(group: &DataOverlayGroup) -> Self {
57        Self {
58            fragment_id: group.fragment_id,
59            overlays: group
60                .overlays
61                .iter()
62                .map(pb::DataOverlayFile::from)
63                .collect(),
64        }
65    }
66}
67
68impl TryFrom<pb::transaction::DataOverlayGroup> for DataOverlayGroup {
69    type Error = Error;
70
71    fn try_from(message: pb::transaction::DataOverlayGroup) -> Result<Self> {
72        Ok(Self {
73            fragment_id: message.fragment_id,
74            overlays: message
75                .overlays
76                .into_iter()
77                .map(DataOverlayFile::try_from)
78                .collect::<Result<Vec<_>>>()?,
79        })
80    }
81}
82
83impl TryFrom<pb::Transaction> for Transaction {
84    type Error = Error;
85
86    fn try_from(message: pb::Transaction) -> Result<Self> {
87        let operation = match message.operation {
88            Some(pb::transaction::Operation::Append(pb::transaction::Append { fragments })) => {
89                Operation::Append {
90                    fragments: fragments
91                        .into_iter()
92                        .map(Fragment::try_from)
93                        .collect::<Result<Vec<_>>>()?,
94                }
95            }
96            Some(pb::transaction::Operation::Clone(pb::transaction::Clone {
97                is_shallow,
98                ref_name,
99                ref_version,
100                ref_path,
101                branch_name,
102            })) => Operation::Clone {
103                is_shallow,
104                ref_name,
105                ref_version,
106                ref_path,
107                branch_name,
108            },
109            Some(pb::transaction::Operation::Delete(pb::transaction::Delete {
110                updated_fragments,
111                deleted_fragment_ids,
112                predicate,
113            })) => Operation::Delete {
114                updated_fragments: updated_fragments
115                    .into_iter()
116                    .map(Fragment::try_from)
117                    .collect::<Result<Vec<_>>>()?,
118                deleted_fragment_ids,
119                predicate,
120            },
121            Some(pb::transaction::Operation::Overwrite(pb::transaction::Overwrite {
122                fragments,
123                schema,
124                schema_metadata: _schema_metadata, // TODO: handle metadata
125                config_upsert_values,
126                initial_bases,
127            })) => {
128                let config_upsert_option = if config_upsert_values.is_empty() {
129                    None
130                } else {
131                    Some(config_upsert_values)
132                };
133
134                Operation::Overwrite {
135                    fragments: fragments
136                        .into_iter()
137                        .map(Fragment::try_from)
138                        .collect::<Result<Vec<_>>>()?,
139                    schema: Schema::try_from(&Fields(schema))?,
140                    config_upsert_values: config_upsert_option,
141                    initial_bases: if initial_bases.is_empty() {
142                        None
143                    } else {
144                        Some(initial_bases.into_iter().map(BasePath::from).collect())
145                    },
146                }
147            }
148            Some(pb::transaction::Operation::ReserveFragments(
149                pb::transaction::ReserveFragments { num_fragments },
150            )) => Operation::ReserveFragments { num_fragments },
151            Some(pb::transaction::Operation::Rewrite(pb::transaction::Rewrite {
152                old_fragments,
153                new_fragments,
154                groups,
155                rewritten_indices,
156            })) => {
157                let groups = if !groups.is_empty() {
158                    groups
159                        .into_iter()
160                        .map(RewriteGroup::try_from)
161                        .collect::<Result<_>>()?
162                } else {
163                    vec![RewriteGroup {
164                        old_fragments: old_fragments
165                            .into_iter()
166                            .map(Fragment::try_from)
167                            .collect::<Result<Vec<_>>>()?,
168                        new_fragments: new_fragments
169                            .into_iter()
170                            .map(Fragment::try_from)
171                            .collect::<Result<Vec<_>>>()?,
172                    }]
173                };
174                let rewritten_indices = rewritten_indices
175                    .iter()
176                    .map(RewrittenIndex::try_from)
177                    .collect::<Result<_>>()?;
178
179                Operation::Rewrite {
180                    groups,
181                    rewritten_indices,
182                    frag_reuse_index: None,
183                }
184            }
185            Some(pb::transaction::Operation::CreateIndex(pb::transaction::CreateIndex {
186                new_indices,
187                removed_indices,
188            })) => Operation::CreateIndex {
189                new_indices: new_indices
190                    .into_iter()
191                    .map(IndexMetadata::try_from)
192                    .collect::<Result<_>>()?,
193                removed_indices: removed_indices
194                    .into_iter()
195                    .map(IndexMetadata::try_from)
196                    .collect::<Result<_>>()?,
197            },
198            Some(pb::transaction::Operation::Merge(pb::transaction::Merge {
199                fragments,
200                schema,
201                schema_metadata: _schema_metadata, // TODO: handle metadata
202                preserves_nullability,
203            })) => Operation::Merge {
204                fragments: fragments
205                    .into_iter()
206                    .map(Fragment::try_from)
207                    .collect::<Result<Vec<_>>>()?,
208                schema: Schema::try_from(&Fields(schema))?,
209                // False for a writer that predates the field: no assertion, so
210                // a legacy required-field merge still conflicts and a legacy
211                // nullable merge over-conflicts, which only retries.
212                preserves_nullability,
213            },
214            Some(pb::transaction::Operation::Restore(pb::transaction::Restore { version })) => {
215                Operation::Restore { version }
216            }
217            Some(pb::transaction::Operation::Update(pb::transaction::Update {
218                removed_fragment_ids,
219                updated_fragments,
220                new_fragments,
221                fields_modified,
222                compacted_sstables,
223                fields_for_preserving_frag_bitmap,
224                update_mode,
225                inserted_rows,
226                updated_fragment_offsets,
227                updated_fragment_offset_bitmaps,
228            })) => Operation::Update {
229                removed_fragment_ids,
230                updated_fragments: updated_fragments
231                    .into_iter()
232                    .map(Fragment::try_from)
233                    .collect::<Result<Vec<_>>>()?,
234                new_fragments: new_fragments
235                    .into_iter()
236                    .map(Fragment::try_from)
237                    .collect::<Result<Vec<_>>>()?,
238                fields_modified,
239                compacted_sstables: compacted_sstables
240                    .into_iter()
241                    .map(|m| CompactedSsTable::try_from(m).unwrap())
242                    .collect(),
243                fields_for_preserving_frag_bitmap,
244                update_mode: match update_mode {
245                    0 => Some(UpdateMode::RewriteRows),
246                    1 => Some(UpdateMode::RewriteColumns),
247                    _ => Some(UpdateMode::RewriteRows),
248                },
249                inserted_rows_filter: inserted_rows
250                    .map(|ik| KeyExistenceFilter::try_from(&ik))
251                    .transpose()?,
252                updated_fragment_offsets: {
253                    // Prefer field 10 (RoaringBitmap bytes); fall back to field 9 (UInt32List)
254                    // for manifests written before this change.
255                    let m: HashMap<u64, RoaringBitmap> =
256                        if !updated_fragment_offset_bitmaps.is_empty() {
257                            updated_fragment_offset_bitmaps
258                                .into_iter()
259                                .filter(|(_, bytes)| !bytes.is_empty())
260                                .map(|(id, bytes)| {
261                                    let bitmap = RoaringBitmap::deserialize_from(bytes.as_slice())
262                                        .map_err(|e| {
263                                            Error::invalid_input(format!(
264                                                "invalid updated_fragment_offset_bitmaps \
265                                                     for fragment {id}: {e}"
266                                            ))
267                                        })?;
268                                    Ok((id, bitmap))
269                                })
270                                .collect::<Result<HashMap<_, _>>>()?
271                        } else {
272                            updated_fragment_offsets
273                                .into_iter()
274                                .filter(|(_, list)| !list.values.is_empty())
275                                .map(|(id, list)| (id, RoaringBitmap::from_iter(list.values)))
276                                .collect()
277                        };
278                    if m.is_empty() {
279                        None
280                    } else {
281                        Some(UpdatedFragmentOffsets(m))
282                    }
283                },
284            },
285            Some(pb::transaction::Operation::Project(pb::transaction::Project {
286                schema,
287                preserves_nullability,
288            })) => Operation::Project {
289                schema: Schema::try_from(&Fields(schema))?,
290                // False for a writer that predates the field: no assertion, so
291                // a legacy tightening still conflicts and a legacy rename
292                // over-conflicts, which only retries.
293                preserves_nullability,
294            },
295            Some(pb::transaction::Operation::UpdateConfig(update_config)) => {
296                // Check if new-style fields are present
297                let has_new_fields = update_config.config_updates.is_some()
298                    || update_config.table_metadata_updates.is_some()
299                    || update_config.schema_metadata_updates.is_some()
300                    || !update_config.field_metadata_updates.is_empty();
301
302                // Check if old-style fields are present
303                let has_old_fields = !update_config.upsert_values.is_empty()
304                    || !update_config.delete_keys.is_empty()
305                    || !update_config.schema_metadata.is_empty()
306                    || !update_config.field_metadata.is_empty();
307
308                // Error if both are present
309                if has_new_fields && has_old_fields {
310                    return Err(Error::invalid_input_source(
311                        "Cannot mix old and new style UpdateConfig fields".into(),
312                    ));
313                }
314
315                if has_old_fields {
316                    // Translate old-style to new-style
317                    let config_updates = if !update_config.upsert_values.is_empty()
318                        || !update_config.delete_keys.is_empty()
319                    {
320                        Some(translate_config_updates(
321                            &update_config.upsert_values,
322                            &update_config.delete_keys,
323                        ))
324                    } else {
325                        None
326                    };
327
328                    let schema_metadata_updates = if !update_config.schema_metadata.is_empty() {
329                        Some(translate_schema_metadata_updates(
330                            &update_config.schema_metadata,
331                        ))
332                    } else {
333                        None
334                    };
335
336                    let field_metadata_updates = update_config
337                        .field_metadata
338                        .into_iter()
339                        .map(|(field_id, field_meta_update)| {
340                            (
341                                field_id as i32,
342                                translate_schema_metadata_updates(&field_meta_update.metadata),
343                            )
344                        })
345                        .collect();
346
347                    Operation::UpdateConfig {
348                        config_updates,
349                        table_metadata_updates: None,
350                        schema_metadata_updates,
351                        field_metadata_updates,
352                    }
353                } else {
354                    // Use new-style fields directly (convert from protobuf)
355                    Operation::UpdateConfig {
356                        config_updates: update_config.config_updates.as_ref().map(UpdateMap::from),
357                        table_metadata_updates: update_config
358                            .table_metadata_updates
359                            .as_ref()
360                            .map(UpdateMap::from),
361                        schema_metadata_updates: update_config
362                            .schema_metadata_updates
363                            .as_ref()
364                            .map(UpdateMap::from),
365                        field_metadata_updates: update_config
366                            .field_metadata_updates
367                            .iter()
368                            .map(|(field_id, pb_update_map)| {
369                                (*field_id, UpdateMap::from(pb_update_map))
370                            })
371                            .collect(),
372                    }
373                }
374            }
375            Some(pb::transaction::Operation::DataReplacement(
376                pb::transaction::DataReplacement { replacements },
377            )) => Operation::DataReplacement {
378                replacements: replacements
379                    .into_iter()
380                    .map(DataReplacementGroup::try_from)
381                    .collect::<Result<Vec<_>>>()?,
382            },
383            Some(pb::transaction::Operation::UpdateMemWalState(
384                pb::transaction::UpdateMemWalState { compacted_sstables },
385            )) => Operation::UpdateMemWalState {
386                compacted_sstables: compacted_sstables
387                    .into_iter()
388                    .map(CompactedSsTable::try_from)
389                    .collect::<Result<_>>()?,
390            },
391            Some(pb::transaction::Operation::UpdateBases(pb::transaction::UpdateBases {
392                new_bases,
393            })) => Operation::UpdateBases {
394                new_bases: new_bases.into_iter().map(BasePath::from).collect(),
395            },
396            Some(pb::transaction::Operation::DataOverlay(pb::transaction::DataOverlay {
397                groups,
398            })) => Operation::DataOverlay {
399                groups: groups
400                    .into_iter()
401                    .map(DataOverlayGroup::try_from)
402                    .collect::<Result<Vec<_>>>()?,
403            },
404            None => {
405                return Err(Error::internal(
406                    "Transaction message did not contain an operation".to_string(),
407                ));
408            }
409        };
410        Ok(Self {
411            read_version: message.read_version,
412            uuid: message.uuid.clone(),
413            operation,
414            tag: if message.tag.is_empty() {
415                None
416            } else {
417                Some(message.tag.clone())
418            },
419            transaction_properties: if message.transaction_properties.is_empty() {
420                None
421            } else {
422                Some(Arc::new(message.transaction_properties))
423            },
424        })
425    }
426}
427
428impl TryFrom<&pb::transaction::rewrite::RewrittenIndex> for RewrittenIndex {
429    type Error = Error;
430
431    fn try_from(message: &pb::transaction::rewrite::RewrittenIndex) -> Result<Self> {
432        Ok(Self {
433            old_id: message
434                .old_id
435                .as_ref()
436                .map(Uuid::try_from)
437                .ok_or_else(|| {
438                    Error::invalid_input("required field (old_id) missing from message".to_string())
439                })??,
440            new_id: message
441                .new_id
442                .as_ref()
443                .map(Uuid::try_from)
444                .ok_or_else(|| {
445                    Error::invalid_input("required field (new_id) missing from message".to_string())
446                })??,
447            new_index_details: message
448                .new_index_details
449                .as_ref()
450                .ok_or_else(|| {
451                    Error::invalid_input("new_index_details is a required field".to_string())
452                })?
453                .clone(),
454            new_index_version: message.new_index_version,
455            new_index_files: if message.new_index_files.is_empty() {
456                None
457            } else {
458                Some(
459                    message
460                        .new_index_files
461                        .iter()
462                        .map(|f| IndexFile {
463                            path: f.path.clone(),
464                            size_bytes: f.size_bytes,
465                        })
466                        .collect(),
467                )
468            },
469        })
470    }
471}
472
473impl TryFrom<pb::transaction::rewrite::RewriteGroup> for RewriteGroup {
474    type Error = Error;
475
476    fn try_from(message: pb::transaction::rewrite::RewriteGroup) -> Result<Self> {
477        Ok(Self {
478            old_fragments: message
479                .old_fragments
480                .into_iter()
481                .map(Fragment::try_from)
482                .collect::<Result<Vec<_>>>()?,
483            new_fragments: message
484                .new_fragments
485                .into_iter()
486                .map(Fragment::try_from)
487                .collect::<Result<Vec<_>>>()?,
488        })
489    }
490}
491
492impl From<&Transaction> for pb::Transaction {
493    fn from(value: &Transaction) -> Self {
494        let operation = match &value.operation {
495            Operation::Append { fragments } => {
496                pb::transaction::Operation::Append(pb::transaction::Append {
497                    fragments: fragments.iter().map(pb::DataFragment::from).collect(),
498                })
499            }
500            Operation::Clone {
501                is_shallow,
502                ref_name,
503                ref_version,
504                ref_path,
505                branch_name,
506            } => pb::transaction::Operation::Clone(pb::transaction::Clone {
507                is_shallow: *is_shallow,
508                ref_name: ref_name.clone(),
509                ref_version: *ref_version,
510                ref_path: ref_path.clone(),
511                branch_name: branch_name.clone(),
512            }),
513            Operation::Delete {
514                updated_fragments,
515                deleted_fragment_ids,
516                predicate,
517            } => pb::transaction::Operation::Delete(pb::transaction::Delete {
518                updated_fragments: updated_fragments
519                    .iter()
520                    .map(pb::DataFragment::from)
521                    .collect(),
522                deleted_fragment_ids: deleted_fragment_ids.clone(),
523                predicate: predicate.clone(),
524            }),
525            Operation::Overwrite {
526                fragments,
527                schema,
528                config_upsert_values,
529                initial_bases,
530            } => {
531                pb::transaction::Operation::Overwrite(pb::transaction::Overwrite {
532                    fragments: fragments.iter().map(pb::DataFragment::from).collect(),
533                    schema: Fields::from(schema).0,
534                    schema_metadata: Default::default(), // TODO: handle metadata
535                    config_upsert_values: config_upsert_values
536                        .clone()
537                        .unwrap_or(Default::default()),
538                    initial_bases: initial_bases
539                        .as_ref()
540                        .map(|paths| {
541                            paths
542                                .iter()
543                                .cloned()
544                                .map(|bp: BasePath| -> pb::BasePath { bp.into() })
545                                .collect::<Vec<pb::BasePath>>()
546                        })
547                        .unwrap_or_default(),
548                })
549            }
550            Operation::ReserveFragments { num_fragments } => {
551                pb::transaction::Operation::ReserveFragments(pb::transaction::ReserveFragments {
552                    num_fragments: *num_fragments,
553                })
554            }
555            Operation::Rewrite {
556                groups,
557                rewritten_indices,
558                frag_reuse_index: _,
559            } => pb::transaction::Operation::Rewrite(pb::transaction::Rewrite {
560                groups: groups
561                    .iter()
562                    .map(pb::transaction::rewrite::RewriteGroup::from)
563                    .collect(),
564                rewritten_indices: rewritten_indices
565                    .iter()
566                    .map(|rewritten| rewritten.into())
567                    .collect(),
568                ..Default::default()
569            }),
570            Operation::CreateIndex {
571                new_indices,
572                removed_indices,
573            } => pb::transaction::Operation::CreateIndex(pb::transaction::CreateIndex {
574                new_indices: new_indices.iter().map(pb::IndexMetadata::from).collect(),
575                removed_indices: removed_indices
576                    .iter()
577                    .map(pb::IndexMetadata::from)
578                    .collect(),
579            }),
580            Operation::Merge {
581                fragments,
582                schema,
583                preserves_nullability,
584            } => pb::transaction::Operation::Merge(pb::transaction::Merge {
585                fragments: fragments.iter().map(pb::DataFragment::from).collect(),
586                schema: Fields::from(schema).0,
587                schema_metadata: Default::default(), // TODO: handle metadata
588                preserves_nullability: *preserves_nullability,
589            }),
590            Operation::Restore { version } => {
591                pb::transaction::Operation::Restore(pb::transaction::Restore { version: *version })
592            }
593            Operation::Update {
594                removed_fragment_ids,
595                updated_fragments,
596                new_fragments,
597                fields_modified,
598                compacted_sstables,
599                fields_for_preserving_frag_bitmap,
600                update_mode,
601                inserted_rows_filter,
602                updated_fragment_offsets,
603            } => pb::transaction::Operation::Update(pb::transaction::Update {
604                removed_fragment_ids: removed_fragment_ids.clone(),
605                updated_fragments: updated_fragments
606                    .iter()
607                    .map(pb::DataFragment::from)
608                    .collect(),
609                new_fragments: new_fragments.iter().map(pb::DataFragment::from).collect(),
610                fields_modified: fields_modified.clone(),
611                compacted_sstables: compacted_sstables
612                    .iter()
613                    .map(pb::CompactedSsTable::from)
614                    .collect(),
615                fields_for_preserving_frag_bitmap: fields_for_preserving_frag_bitmap.clone(),
616                update_mode: update_mode
617                    .as_ref()
618                    .map(|mode| match mode {
619                        UpdateMode::RewriteRows => 0,
620                        UpdateMode::RewriteColumns => 1,
621                    })
622                    .unwrap_or(0),
623                inserted_rows: inserted_rows_filter.as_ref().map(|ik| ik.into()),
624                // Field 9: no longer written; kept empty for forward compat.
625                updated_fragment_offsets: HashMap::new(),
626                // Field 10: RoaringBitmap bytes.
627                updated_fragment_offset_bitmaps: updated_fragment_offsets
628                    .as_ref()
629                    .map(|UpdatedFragmentOffsets(m)| {
630                        m.iter()
631                            .filter(|(_, b)| !b.is_empty())
632                            .map(|(frag_id, b)| {
633                                let mut buf = Vec::new();
634                                b.serialize_into(&mut buf)
635                                    .expect("RoaringBitmap serialization cannot fail");
636                                (*frag_id, buf)
637                            })
638                            .collect::<HashMap<_, _>>()
639                    })
640                    .unwrap_or_default(),
641            }),
642            Operation::Project {
643                schema,
644                preserves_nullability,
645            } => pb::transaction::Operation::Project(pb::transaction::Project {
646                schema: Fields::from(schema).0,
647                preserves_nullability: *preserves_nullability,
648            }),
649            Operation::UpdateConfig {
650                config_updates,
651                table_metadata_updates,
652                schema_metadata_updates,
653                field_metadata_updates,
654            } => pb::transaction::Operation::UpdateConfig(pb::transaction::UpdateConfig {
655                config_updates: config_updates
656                    .as_ref()
657                    .map(pb::transaction::UpdateMap::from),
658                table_metadata_updates: table_metadata_updates
659                    .as_ref()
660                    .map(pb::transaction::UpdateMap::from),
661                schema_metadata_updates: schema_metadata_updates
662                    .as_ref()
663                    .map(pb::transaction::UpdateMap::from),
664                field_metadata_updates: field_metadata_updates
665                    .iter()
666                    .map(|(field_id, update_map)| {
667                        (*field_id, pb::transaction::UpdateMap::from(update_map))
668                    })
669                    .collect(),
670                // Leave old fields empty - we only write new-style fields
671                upsert_values: Default::default(),
672                delete_keys: Default::default(),
673                schema_metadata: Default::default(),
674                field_metadata: Default::default(),
675            }),
676            Operation::DataReplacement { replacements } => {
677                pb::transaction::Operation::DataReplacement(pb::transaction::DataReplacement {
678                    replacements: replacements
679                        .iter()
680                        .map(pb::transaction::DataReplacementGroup::from)
681                        .collect(),
682                })
683            }
684            Operation::DataOverlay { groups } => {
685                pb::transaction::Operation::DataOverlay(pb::transaction::DataOverlay {
686                    groups: groups
687                        .iter()
688                        .map(pb::transaction::DataOverlayGroup::from)
689                        .collect(),
690                })
691            }
692            Operation::UpdateMemWalState { compacted_sstables } => {
693                pb::transaction::Operation::UpdateMemWalState(pb::transaction::UpdateMemWalState {
694                    compacted_sstables: compacted_sstables
695                        .iter()
696                        .map(pb::CompactedSsTable::from)
697                        .collect::<Vec<_>>(),
698                })
699            }
700            Operation::UpdateBases { new_bases } => {
701                pb::transaction::Operation::UpdateBases(pb::transaction::UpdateBases {
702                    new_bases: new_bases
703                        .iter()
704                        .cloned()
705                        .map(|bp: BasePath| -> pb::BasePath { bp.into() })
706                        .collect::<Vec<pb::BasePath>>(),
707                })
708            }
709        };
710
711        let transaction_properties = value
712            .transaction_properties
713            .as_ref()
714            .map(|arc| arc.as_ref().clone())
715            .unwrap_or_default();
716        Self {
717            read_version: value.read_version,
718            uuid: value.uuid.clone(),
719            operation: Some(operation),
720            tag: value.tag.clone().unwrap_or("".to_string()),
721            transaction_properties,
722        }
723    }
724}
725
726impl From<&RewrittenIndex> for pb::transaction::rewrite::RewrittenIndex {
727    fn from(value: &RewrittenIndex) -> Self {
728        Self {
729            old_id: Some((&value.old_id).into()),
730            new_id: Some((&value.new_id).into()),
731            new_index_details: Some(value.new_index_details.clone()),
732            new_index_version: value.new_index_version,
733            new_index_files: value
734                .new_index_files
735                .as_ref()
736                .map(|files| {
737                    files
738                        .iter()
739                        .map(|f| pb::IndexFile {
740                            path: f.path.clone(),
741                            size_bytes: f.size_bytes,
742                        })
743                        .collect()
744                })
745                .unwrap_or_default(),
746        }
747    }
748}
749
750impl From<&RewriteGroup> for pb::transaction::rewrite::RewriteGroup {
751    fn from(value: &RewriteGroup) -> Self {
752        Self {
753            old_fragments: value
754                .old_fragments
755                .iter()
756                .map(pb::DataFragment::from)
757                .collect(),
758            new_fragments: value
759                .new_fragments
760                .iter()
761                .map(pb::DataFragment::from)
762                .collect(),
763        }
764    }
765}
766
767impl From<&UpdateMap> for pb::transaction::UpdateMap {
768    fn from(update_map: &UpdateMap) -> Self {
769        Self {
770            update_entries: update_map
771                .update_entries
772                .iter()
773                .map(|entry| pb::transaction::UpdateMapEntry {
774                    key: entry.key.clone(),
775                    value: entry.value.clone(),
776                })
777                .collect(),
778            replace: update_map.replace,
779        }
780    }
781}
782
783impl From<&pb::transaction::UpdateMap> for UpdateMap {
784    fn from(pb_update_map: &pb::transaction::UpdateMap) -> Self {
785        Self {
786            update_entries: pb_update_map
787                .update_entries
788                .iter()
789                .map(|entry| UpdateMapEntry {
790                    key: entry.key.clone(),
791                    value: entry.value.clone(),
792                })
793                .collect(),
794            replace: pb_update_map.replace,
795        }
796    }
797}
798
799impl From<&Transaction> for crate::format::Transaction {
800    fn from(value: &Transaction) -> Self {
801        let pb_transaction: pb::Transaction = value.into();
802        Self {
803            inner: pb_transaction,
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use crate::format::DataFile;
812    use crate::format::overlay::OverlayCoverage;
813
814    #[test]
815    fn test_data_overlay_operation_roundtrips() {
816        // A DataOverlay operation survives the protobuf round-trip, preserving
817        // the target fragment, the overlay's coverage, and its committed_version.
818        let mut bitmap = roaring::RoaringBitmap::new();
819        bitmap.insert(1);
820        bitmap.insert(4);
821        let overlay = DataOverlayFile {
822            data_file: DataFile::new_legacy_from_fields("overlay-0.lance", vec![3], None),
823            coverage: OverlayCoverage::dense(bitmap.clone()),
824            committed_version: 6,
825        };
826        let pb_overlay = pb::DataOverlayFile::from(&overlay);
827
828        let message = pb::Transaction {
829            read_version: 1,
830            uuid: Uuid::new_v4().to_string(),
831            operation: Some(pb::transaction::Operation::DataOverlay(
832                pb::transaction::DataOverlay {
833                    groups: vec![pb::transaction::DataOverlayGroup {
834                        fragment_id: 7,
835                        overlays: vec![pb_overlay],
836                    }],
837                },
838            )),
839            ..Default::default()
840        };
841
842        let txn = Transaction::try_from(message).unwrap();
843        match txn.operation {
844            Operation::DataOverlay { groups } => {
845                assert_eq!(groups.len(), 1);
846                assert_eq!(groups[0].fragment_id, 7);
847                assert_eq!(groups[0].overlays.len(), 1);
848                assert_eq!(groups[0].overlays[0].committed_version, 6);
849                assert_eq!(
850                    *groups[0].overlays[0].coverage_for_field(0).unwrap(),
851                    bitmap
852                );
853            }
854            other => panic!("expected DataOverlay, got {other:?}"),
855        }
856    }
857}