diff --git a/Cargo.lock b/Cargo.lock index c9d3aad3a88..718245b32a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5133,6 +5133,7 @@ name = "lance-tools" version = "6.0.0-beta.2" dependencies = [ "clap", + "lance", "lance-core", "lance-file", "lance-io", diff --git a/protos/transaction.proto b/protos/transaction.proto index 06268feb252..5776d2f229f 100644 --- a/protos/transaction.proto +++ b/protos/transaction.proto @@ -316,10 +316,26 @@ message Transaction { repeated MergedGeneration merged_generations = 1; } + // Assigns a base path to all data files within a fragment. + // Used as part of UpdateBases to atomically register new base paths and + // reassign existing fragment data files in a single commit. + message FragmentBaseAssignment { + // The ID of the fragment whose data files should be reassigned. + uint64 fragment_id = 1; + // The base path ID to assign to this fragment's data files. + // When absent, the fragment's data files are assigned to the dataset root + // (any existing base_id is cleared). + optional uint32 base_id = 2; + } + // An operation that updates base paths in the dataset. message UpdateBases { // The new base paths to add to the manifest. repeated BasePath new_bases = 1; + // Optional per-fragment base reassignments applied atomically with the new + // base registrations. Each entry reassigns all data files in the named + // fragment to the specified base. Fragments not listed are unchanged. + repeated FragmentBaseAssignment fragment_assignments = 2; } // The operation of this transaction. diff --git a/rust/lance-table/src/format.rs b/rust/lance-table/src/format.rs index 842c76f1e58..09ae005a9f3 100644 --- a/rust/lance-table/src/format.rs +++ b/rust/lance-table/src/format.rs @@ -16,8 +16,8 @@ pub use fragment::*; pub use index::{IndexFile, IndexMetadata, index_metadata_codec, list_index_files_with_sizes}; pub use manifest::{ - BasePath, DETACHED_VERSION_MASK, DataStorageFormat, Manifest, SelfDescribingFileReader, - WriterVersion, is_detached_version, + BasePath, DETACHED_VERSION_MASK, DataStorageFormat, FragmentBaseAssignment, Manifest, + SelfDescribingFileReader, WriterVersion, is_detached_version, }; pub use transaction::Transaction; diff --git a/rust/lance-table/src/format/manifest.rs b/rust/lance-table/src/format/manifest.rs index d2b5f2d31c6..fe05af6c6f9 100644 --- a/rust/lance-table/src/format/manifest.rs +++ b/rust/lance-table/src/format/manifest.rs @@ -840,6 +840,39 @@ impl From for pb::BasePath { } } +/// Assigns a base path to all data files within a fragment. +/// +/// Used as part of `UpdateBases` to atomically register new base paths and +/// reassign existing fragment data files in a single commit. No data is moved; +/// the caller is responsible for ensuring the data already exists at the +/// target base location. +#[derive(Debug, Clone, PartialEq, DeepSizeOf)] +pub struct FragmentBaseAssignment { + /// ID of the fragment whose data files should be reassigned. + pub fragment_id: u64, + /// The base path ID to assign. `None` clears any existing base_id, + /// causing the fragment's data files to resolve against the dataset root. + pub base_id: Option, +} + +impl From for FragmentBaseAssignment { + fn from(p: pb::transaction::FragmentBaseAssignment) -> Self { + Self { + fragment_id: p.fragment_id, + base_id: p.base_id, + } + } +} + +impl From for pb::transaction::FragmentBaseAssignment { + fn from(a: FragmentBaseAssignment) -> Self { + Self { + fragment_id: a.fragment_id, + base_id: a.base_id, + } + } +} + impl TryFrom for Manifest { type Error = Error; diff --git a/rust/lance-tools/Cargo.toml b/rust/lance-tools/Cargo.toml index b284e4a795c..42b0db53791 100644 --- a/rust/lance-tools/Cargo.toml +++ b/rust/lance-tools/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" [dependencies] clap = { workspace = true, features = ["derive"] } +lance = { workspace = true, default-features = false } lance-core.workspace = true lance-file.workspace = true lance-io.workspace = true diff --git a/rust/lance-tools/src/cli.rs b/rust/lance-tools/src/cli.rs index 78e39c38504..a76e2f9fc6e 100644 --- a/rust/lance-tools/src/cli.rs +++ b/rust/lance-tools/src/cli.rs @@ -20,6 +20,8 @@ pub struct LanceToolsArgs { pub enum LanceToolsCommand { /// Commands for interacting with Lance files. File(LanceFileArgs), + /// Commands for interacting with Lance tables. + Table(LanceTableArgs), } #[derive(Parser, Debug)] @@ -41,12 +43,48 @@ pub struct LanceFileMetaArgs { pub(crate) source: String, } +#[derive(Parser, Debug)] +pub struct LanceTableArgs { + #[command(subcommand)] + pub(crate) command: LanceTableCommand, +} + +#[derive(Subcommand, Debug)] +pub enum LanceTableCommand { + /// Convert a single-base Lance table into a multi-base table. + /// + /// The caller must have already copied the full dataset directory to each + /// additional base URI (e.g. with `azcopy` or `gsutil rsync`) before + /// running this command. Only metadata is updated; no data is moved. + /// + /// Example: + /// lance-tools table to-multi-base \ + /// --source az://container1/mydata \ + /// --additional-base az://container2/mydata \ + /// --additional-base az://container3/mydata + ToMultiBase(LanceTableToMultiBaseArgs), +} + +#[derive(Args, Debug)] +pub struct LanceTableToMultiBaseArgs { + /// URI of the existing (source) Lance dataset. + #[arg(short = 's', long, value_name = "source")] + pub source: String, + + /// URI of an additional copy of the dataset. Specify once per copy. + #[arg(long = "additional-base", value_name = "URI")] + pub additional_base: Vec, +} + impl LanceToolsArgs { pub async fn run(&self, writer: impl std::io::Write) -> Result<()> { match &self.command { LanceToolsCommand::File(args) => match &args.command { LanceFileCommand::Meta(args) => crate::meta::show_file_meta(writer, args).await, }, + LanceToolsCommand::Table(args) => match &args.command { + LanceTableCommand::ToMultiBase(args) => crate::table::to_multi_base(args).await, + }, } } } diff --git a/rust/lance-tools/src/lib.rs b/rust/lance-tools/src/lib.rs index afef7b6c735..3a17036ac90 100644 --- a/rust/lance-tools/src/lib.rs +++ b/rust/lance-tools/src/lib.rs @@ -3,4 +3,5 @@ pub mod cli; pub mod meta; +pub mod table; pub mod util; diff --git a/rust/lance-tools/src/table.rs b/rust/lance-tools/src/table.rs new file mode 100644 index 00000000000..64353fc0a16 --- /dev/null +++ b/rust/lance-tools/src/table.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use crate::cli::LanceTableToMultiBaseArgs; +use lance::Dataset; +use lance_core::Result; +use std::sync::Arc; + +pub(crate) async fn to_multi_base(args: &LanceTableToMultiBaseArgs) -> Result<()> { + let dataset = Arc::new(Dataset::open(&args.source).await?); + let result = dataset + .to_multi_base(args.additional_base.clone(), None) + .await?; + + let n_bases = result.manifest.base_paths.len(); + let n_frags = result.fragments().len(); + + // Print a brief summary to stdout. + println!( + "Converted '{}' to multi-base: {} fragments distributed across {} additional base(s).", + args.source, n_frags, n_bases, + ); + Ok(()) +} diff --git a/rust/lance/src/dataset.rs b/rust/lance/src/dataset.rs index 8a7a9cf3636..5205e010d77 100644 --- a/rust/lance/src/dataset.rs +++ b/rust/lance/src/dataset.rs @@ -1633,7 +1633,10 @@ impl Dataset { new_bases: Vec, transaction_properties: Option>, ) -> Result { - let operation = Operation::UpdateBases { new_bases }; + let operation = Operation::UpdateBases { + new_bases, + fragment_assignments: vec![], + }; let transaction = TransactionBuilder::new(self.manifest.version, operation) .transaction_properties(transaction_properties.map(Arc::new)) @@ -1646,6 +1649,93 @@ impl Dataset { Ok(new_dataset) } + /// Convert a single-base dataset into a multi-base dataset. + /// + /// This is a **metadata-only** operation. The caller must have already + /// copied the dataset directory to each additional URI (e.g. with `azcopy` + /// or `gsutil rsync`) before calling this method. + /// + /// Existing fragments are distributed round-robin across the original base + /// and each additional base, giving each base approximately + /// `total_fragments / (1 + additional_uris.len())` fragments. + /// + /// # Arguments + /// * `additional_uris` – Full URIs of the additional dataset copies, one per + /// new base (e.g. `"az://container2/path"`, `"/local/copy"`). + /// * `transaction_properties` – Optional key-value metadata forwarded to the + /// commit transaction. + /// + /// # Returns + /// A new `Dataset` handle pointing at the updated manifest. + pub async fn to_multi_base( + self: &Arc, + additional_uris: Vec, + transaction_properties: Option>, + ) -> Result { + if additional_uris.is_empty() { + return Err(Error::invalid_input( + "to_multi_base requires at least one additional URI".to_string(), + )); + } + + // Choose IDs for the new bases, starting above the current maximum. + let start_id: u32 = self + .manifest + .base_paths + .keys() + .max() + .map(|&m| m + 1) + .unwrap_or(1); + + let new_bases: Vec = additional_uris + .iter() + .enumerate() + .map(|(i, uri)| { + lance_table::format::BasePath::new( + start_id + i as u32, + uri.clone(), + None, + true, // is_dataset_root: data lives under /data/ + ) + }) + .collect(); + + // Distribute fragments round-robin. + // slot 0 → keep default base (no base_id change) + // slot 1 → new base with id `start_id` + // slot 2 → new base with id `start_id + 1` + // … + let n_slots = (additional_uris.len() + 1) as u64; + let fragment_assignments: Vec = self + .fragments() + .iter() + .filter_map(|frag| { + let slot = frag.id % n_slots; + if slot == 0 { + None // stays on the original/default base + } else { + Some(lance_table::format::FragmentBaseAssignment { + fragment_id: frag.id, + base_id: Some(start_id + (slot - 1) as u32), + }) + } + }) + .collect(); + + let operation = Operation::UpdateBases { + new_bases, + fragment_assignments, + }; + + let transaction = TransactionBuilder::new(self.manifest.version, operation) + .transaction_properties(transaction_properties.map(Arc::new)) + .build(); + + CommitBuilder::new(self.clone()) + .execute(transaction) + .await + } + pub async fn count_deleted_rows(&self) -> Result { futures::stream::iter(self.get_fragments()) .map(|f| async move { f.count_deletions().await }) diff --git a/rust/lance/src/dataset/tests/dataset_concurrency_store.rs b/rust/lance/src/dataset/tests/dataset_concurrency_store.rs index a9c2aa44c38..52aa59754f1 100644 --- a/rust/lance/src/dataset/tests/dataset_concurrency_store.rs +++ b/rust/lance/src/dataset/tests/dataset_concurrency_store.rs @@ -532,3 +532,205 @@ async fn test_concurrent_add_bases_with_data_write() { // Should have both data writes (10 rows total) assert_eq!(final_dataset.count_rows(None).await.unwrap(), 10); } + +// ----- to_multi_base tests ----- + +/// Verify that `to_multi_base` with 2 additional URIs distributes fragments +/// round-robin and registers both new base paths in the manifest. +#[tokio::test] +async fn test_to_multi_base_metadata() { + use lance_testing::datagen::{BatchGenerator, IncrementingInt32}; + use std::sync::Arc; + + // Write 3 fragments (3 rows each) to an in-memory dataset. + let mut data_gen = + BatchGenerator::new().col(Box::new(IncrementingInt32::new().named("id".to_owned()))); + + let dataset = Dataset::write( + data_gen.batch(3), + "memory://multi_base_meta_test", + Some(WriteParams { + mode: WriteMode::Create, + max_rows_per_file: 3, + ..Default::default() + }), + ) + .await + .unwrap(); + + let dataset = Dataset::write( + data_gen.batch(3), + WriteDestination::Dataset(Arc::new(dataset)), + Some(WriteParams { + mode: WriteMode::Append, + max_rows_per_file: 3, + ..Default::default() + }), + ) + .await + .unwrap(); + + let dataset = Arc::new( + Dataset::write( + data_gen.batch(3), + WriteDestination::Dataset(Arc::new(dataset)), + Some(WriteParams { + mode: WriteMode::Append, + max_rows_per_file: 3, + ..Default::default() + }), + ) + .await + .unwrap(), + ); + + assert_eq!(dataset.fragments().len(), 3); + + // Convert to multi-base with two fictional additional URIs. + let updated = dataset + .to_multi_base( + vec![ + "memory://copy1".to_string(), + "memory://copy2".to_string(), + ], + None, + ) + .await + .unwrap(); + + // Two new base paths should be registered. + assert_eq!(updated.manifest.base_paths.len(), 2); + for bp in updated.manifest.base_paths.values() { + assert!(bp.is_dataset_root, "new bases should be dataset roots"); + } + + // With 3 fragments and n_slots=3, distribution is exactly one per slot. + // slot 0 (id % 3 == 0) → default base, slot 1 → base 1, slot 2 → base 2. + let frags = updated.fragments(); + assert_eq!(frags.len(), 3); + + let default_count = frags.iter().filter(|f| f.files[0].base_id.is_none()).count(); + let base1_count = frags + .iter() + .filter(|f| f.files[0].base_id == Some(1)) + .count(); + let base2_count = frags + .iter() + .filter(|f| f.files[0].base_id == Some(2)) + .count(); + + assert_eq!(default_count, 1, "exactly one fragment on the default base"); + assert_eq!(base1_count, 1, "exactly one fragment on base 1"); + assert_eq!(base2_count, 1, "exactly one fragment on base 2"); +} + +/// Verify that after `to_multi_base` the dataset is still fully readable when +/// the data files have been copied to the additional base locations. +#[tokio::test] +async fn test_to_multi_base_readable() { + use lance_core::utils::tempfile::TempDir; + use lance_testing::datagen::{BatchGenerator, IncrementingInt32}; + use std::sync::Arc; + + // --- Write a 3-fragment dataset to a temp directory --- + let root_dir = TempDir::default(); + let source_uri = root_dir.path_str(); + + let mut data_gen = + BatchGenerator::new().col(Box::new(IncrementingInt32::new().named("id".to_owned()))); + + let dataset = Dataset::write( + data_gen.batch(3), + &source_uri, + Some(WriteParams { + mode: WriteMode::Create, + max_rows_per_file: 3, + ..Default::default() + }), + ) + .await + .unwrap(); + + let dataset = Dataset::write( + data_gen.batch(3), + WriteDestination::Dataset(Arc::new(dataset)), + Some(WriteParams { + mode: WriteMode::Append, + max_rows_per_file: 3, + ..Default::default() + }), + ) + .await + .unwrap(); + + let dataset = Arc::new( + Dataset::write( + data_gen.batch(3), + WriteDestination::Dataset(Arc::new(dataset)), + Some(WriteParams { + mode: WriteMode::Append, + max_rows_per_file: 3, + ..Default::default() + }), + ) + .await + .unwrap(), + ); + + let original_rows = dataset.count_rows(None).await.unwrap(); + assert_eq!(original_rows, 9); + assert_eq!(dataset.fragments().len(), 3); + + // --- Copy the full dataset to two additional temp directories --- + let copy1_dir = TempDir::default(); + let copy2_dir = TempDir::default(); + copy_dir_recursive(root_dir.std_path(), copy1_dir.std_path()); + copy_dir_recursive(root_dir.std_path(), copy2_dir.std_path()); + + // Build file:// URIs for the two copies. + let copy1_uri = format!("file://{}", copy1_dir.path_str()); + let copy2_uri = format!("file://{}", copy2_dir.path_str()); + + // --- Convert to multi-base (metadata only) --- + let updated = dataset + .to_multi_base(vec![copy1_uri, copy2_uri], None) + .await + .unwrap(); + + assert_eq!(updated.manifest.base_paths.len(), 2); + + // --- Re-open the dataset and verify all rows are accessible --- + let reopened = Dataset::open(&source_uri).await.unwrap(); + let row_count = reopened.count_rows(None).await.unwrap(); + assert_eq!( + row_count, original_rows, + "all rows should be readable after to_multi_base" + ); + + // Scan to exercise actual file reads across all three bases. + let batches: Vec<_> = reopened + .scan() + .try_into_stream() + .await + .unwrap() + .try_collect() + .await + .unwrap(); + let total_scanned: usize = batches.iter().map(|b| b.num_rows()).sum(); + assert_eq!(total_scanned, original_rows); +} + +/// Recursively copy the contents of `src` into `dst`. +fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) { + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + std::fs::create_dir_all(&dst_path).unwrap(); + copy_dir_recursive(&src_path, &dst_path); + } else { + std::fs::copy(&src_path, &dst_path).unwrap(); + } + } +} diff --git a/rust/lance/src/dataset/transaction.rs b/rust/lance/src/dataset/transaction.rs index 62f30696234..a8fec6c7a50 100644 --- a/rust/lance/src/dataset/transaction.rs +++ b/rust/lance/src/dataset/transaction.rs @@ -27,8 +27,8 @@ use lance_table::feature_flags::{FLAG_STABLE_ROW_IDS, apply_feature_flags}; use lance_table::rowids::read_row_ids; use lance_table::{ format::{ - BasePath, DataFile, DataStorageFormat, Fragment, IndexFile, IndexMetadata, Manifest, - RowIdMeta, pb, + BasePath, DataFile, DataStorageFormat, Fragment, FragmentBaseAssignment, IndexFile, + IndexMetadata, Manifest, RowIdMeta, pb, }, io::{ commit::CommitHandler, @@ -250,10 +250,19 @@ pub enum Operation { branch_name: Option, }, - // Update base paths in the dataset (currently only supports adding new bases). + /// Update base paths in the dataset and optionally reassign existing + /// fragment data files to those bases. + /// + /// New base paths are added to the manifest and, if `fragment_assignments` + /// is non-empty, each listed fragment's data files and deletion file are + /// updated to reference the specified base. No data is moved; the caller + /// must ensure the data already exists at the target base location. UpdateBases { /// The new base paths to add to the manifest. new_bases: Vec, + /// Per-fragment base reassignments applied atomically with the new + /// base registrations. Fragments not listed here are unchanged. + fragment_assignments: Vec, }, } @@ -1039,9 +1048,16 @@ impl PartialEq for Operation { std::mem::discriminant(self) == std::mem::discriminant(other) } - (Self::UpdateBases { new_bases: a }, Self::UpdateBases { new_bases: b }) => { - compare_vec(a, b) - } + ( + Self::UpdateBases { + new_bases: a, + fragment_assignments: fa, + }, + Self::UpdateBases { + new_bases: b, + fragment_assignments: fb, + }, + ) => compare_vec(a, b) && compare_vec(fa, fb), (Self::UpdateBases { .. }, Self::Append { .. }) => { std::mem::discriminant(self) == std::mem::discriminant(other) @@ -2122,10 +2138,33 @@ impl Transaction { merged_generations.clone(), )?; } - Operation::UpdateBases { .. } => { - // UpdateBases operation doesn't modify fragments or indices - // Base paths are handled in the manifest creation section below - final_fragments.extend(maybe_existing_fragments?.clone()); + Operation::UpdateBases { + fragment_assignments, + .. + } => { + let existing_fragments = maybe_existing_fragments?; + if fragment_assignments.is_empty() { + final_fragments.extend(existing_fragments.iter().cloned()); + } else { + let assignment_map: HashMap> = fragment_assignments + .iter() + .map(|a| (a.fragment_id, a.base_id)) + .collect(); + for fragment in existing_fragments.iter() { + if let Some(&base_id) = assignment_map.get(&fragment.id) { + let mut new_frag = fragment.clone(); + for file in &mut new_frag.files { + file.base_id = base_id; + } + if let Some(del) = &mut new_frag.deletion_file { + del.base_id = base_id; + } + final_fragments.push(new_frag); + } else { + final_fragments.push(fragment.clone()); + } + } + } } }; @@ -2224,14 +2263,17 @@ impl Transaction { } // Handle UpdateBases operation to update manifest base_paths - if let Operation::UpdateBases { new_bases } = &self.operation { + if let Operation::UpdateBases { new_bases, .. } = &self.operation { // Validate and add new base paths to the manifest for new_base in new_bases { // Check for conflicts with existing base paths if let Some(existing_base) = manifest .base_paths .values() - .find(|bp| bp.name == new_base.name || bp.path == new_base.path) + .find(|bp| { + (new_base.name.is_some() && bp.name == new_base.name) + || bp.path == new_base.path + }) { return Err(Error::invalid_input(format!( "Conflict detected: Base path with name '{:?}' or path '{}' already exists. Existing: name='{:?}', path='{}'", @@ -2985,8 +3027,13 @@ impl TryFrom for Transaction { }, Some(pb::transaction::Operation::UpdateBases(pb::transaction::UpdateBases { new_bases, + fragment_assignments, })) => Operation::UpdateBases { new_bases: new_bases.into_iter().map(BasePath::from).collect(), + fragment_assignments: fragment_assignments + .into_iter() + .map(FragmentBaseAssignment::from) + .collect(), }, None => { return Err(Error::internal( @@ -3253,13 +3300,21 @@ impl From<&Transaction> for pb::Transaction { .collect::>(), }) } - Operation::UpdateBases { new_bases } => { + Operation::UpdateBases { + new_bases, + fragment_assignments, + } => { pb::transaction::Operation::UpdateBases(pb::transaction::UpdateBases { new_bases: new_bases .iter() .cloned() .map(|bp: BasePath| -> pb::BasePath { bp.into() }) .collect::>(), + fragment_assignments: fragment_assignments + .iter() + .cloned() + .map(pb::transaction::FragmentBaseAssignment::from) + .collect(), }) } }; diff --git a/rust/lance/src/io/commit/conflict_resolver.rs b/rust/lance/src/io/commit/conflict_resolver.rs index ec03ba596db..98fa6552d90 100644 --- a/rust/lance/src/io/commit/conflict_resolver.rs +++ b/rust/lance/src/io/commit/conflict_resolver.rs @@ -1200,10 +1200,15 @@ impl<'a> TransactionRebase<'a> { other_transaction: &Transaction, other_version: u64, ) -> Result<()> { - if let Operation::UpdateBases { new_bases } = &self.transaction.operation { + if let Operation::UpdateBases { + new_bases, + fragment_assignments, + } = &self.transaction.operation + { match &other_transaction.operation { Operation::UpdateBases { new_bases: committed_bases, + fragment_assignments: committed_assignments, } => { // Check if any of the bases being added conflict with committed bases for new_base in new_bases { @@ -1228,6 +1233,20 @@ impl<'a> TransactionRebase<'a> { } } } + // Check for fragment assignment conflicts: two concurrent UpdateBases + // operations must not both reassign the same fragment. + if !fragment_assignments.is_empty() && !committed_assignments.is_empty() { + for new_assign in fragment_assignments { + for committed_assign in committed_assignments { + if new_assign.fragment_id == committed_assign.fragment_id { + return Err(self.incompatible_conflict_err( + other_transaction, + other_version, + )); + } + } + } + } Ok(()) } // UpdateBases doesn't conflict with data operations @@ -2775,6 +2794,7 @@ mod tests { name: Some("base1".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2787,6 +2807,7 @@ mod tests { name: Some("base2".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2811,6 +2832,7 @@ mod tests { name: Some("duplicate_name".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2823,6 +2845,7 @@ mod tests { name: Some("duplicate_name".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2852,6 +2875,7 @@ mod tests { name: Some("base1".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2864,6 +2888,7 @@ mod tests { name: Some("base2".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2893,6 +2918,7 @@ mod tests { name: Some("base1".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2905,6 +2931,7 @@ mod tests { name: Some("base2".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2933,6 +2960,7 @@ mod tests { name: Some("base1".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -2991,6 +3019,7 @@ mod tests { is_dataset_root: false, }, ], + fragment_assignments: vec![], }, ); @@ -3004,6 +3033,7 @@ mod tests { name: Some("base3".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -3033,6 +3063,7 @@ mod tests { name: None, is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -3045,6 +3076,7 @@ mod tests { name: None, is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -3069,6 +3101,7 @@ mod tests { name: Some("base1".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, ); @@ -3081,6 +3114,7 @@ mod tests { name: Some("base2".to_string()), is_dataset_root: false, }], + fragment_assignments: vec![], }, );