1use crate::format::{Fragment, Manifest};
11use crate::io::deletion::relative_deletion_file_path;
12use crate::transaction::{Operation, UpdateMode, UpdatedFragmentOffsets};
13use lance_core::datatypes::{Field, Schema};
14use lance_core::{Error, Result};
15use lance_file::version::ConcreteFileVersion;
16use std::collections::{HashMap, HashSet};
17
18pub fn validate_operation(manifest: Option<&Manifest>, operation: &Operation) -> Result<()> {
20 let manifest = match (manifest, operation) {
21 (
22 None,
23 Operation::Overwrite {
24 fragments, schema, ..
25 },
26 ) => {
27 overwrite_fragments_valid(fragments)?;
29 schema_fragments_valid(None, schema, fragments)?;
30
31 return Ok(());
32 }
33 (None, Operation::Clone { .. }) => return Ok(()),
34 (Some(manifest), _) => manifest,
35 (None, _) => {
36 return Err(Error::invalid_input(format!(
37 "Cannot apply operation {} to non-existent dataset",
38 operation.name()
39 )));
40 }
41 };
42
43 match operation {
44 Operation::Append { fragments } => {
45 schema_fragments_valid(Some(manifest), &manifest.schema, fragments)
47 }
48 Operation::Project { schema, .. } => {
49 schema_fragments_valid(Some(manifest), schema, manifest.fragments.as_ref())
50 }
51 Operation::Merge {
52 fragments, schema, ..
53 } => {
54 merge_fragments_valid(manifest, fragments)?;
55 merge_schema_valid(manifest, schema, fragments)?;
56 schema_fragments_valid(Some(manifest), schema, fragments)
57 }
58 Operation::Overwrite {
59 fragments, schema, ..
60 } => {
61 overwrite_fragments_valid(fragments)?;
62 schema_fragments_valid(None, schema, fragments)
66 }
67 Operation::Update {
68 updated_fragments,
69 new_fragments,
70 updated_fragment_offsets,
71 update_mode,
72 ..
73 } => {
74 schema_fragments_valid(Some(manifest), &manifest.schema, updated_fragments)?;
75 schema_fragments_valid(Some(manifest), &manifest.schema, new_fragments)?;
76 if matches!(update_mode, Some(UpdateMode::RewriteColumns))
82 && let Some(UpdatedFragmentOffsets(off_map)) = updated_fragment_offsets
83 {
84 let updated_ids: HashSet<u64> = updated_fragments.iter().map(|f| f.id).collect();
85 for &frag_id in off_map.keys() {
86 if !updated_ids.contains(&frag_id) {
87 return Err(Error::invalid_input(format!(
88 "updatedFragmentOffsets key {} is not in updated_fragments; \
89 offsets must reference only fragments being rewritten",
90 frag_id
91 )));
92 }
93 }
94 }
95 Ok(())
96 }
97 _ => Ok(()),
98 }
99}
100
101fn overwrite_fragments_valid(fragments: &[Fragment]) -> Result<()> {
106 for fragment in fragments {
107 if let Some(deletion_file) = &fragment.deletion_file {
108 return Err(Error::invalid_input(format!(
109 "Overwrite fragments must be newly written, but fragment {} carries \
110 deletion file {}. Use Delete to commit deletions against existing \
111 fragments, or Merge to change their schema.",
112 fragment.id,
113 relative_deletion_file_path(fragment.id, deletion_file)
114 )));
115 }
116 }
117 Ok(())
118}
119
120fn schema_fragments_valid(
121 manifest: Option<&Manifest>,
122 schema: &Schema,
123 fragments: &[Fragment],
124) -> Result<()> {
125 if let Some(manifest) = manifest {
126 return match manifest.data_storage_format.lance_file_format() {
127 ConcreteFileVersion::V1 => schema_fragments_legacy_valid(schema, fragments),
128 ConcreteFileVersion::V2_0
129 | ConcreteFileVersion::V2_1
130 | ConcreteFileVersion::V2_2
131 | ConcreteFileVersion::V2_3 => schema_fragments_modern_valid(schema, fragments),
132 };
133 }
134 schema_fragments_modern_valid(schema, fragments)
135}
136
137pub fn schema_fragments_modern_valid(_schema: &Schema, fragments: &[Fragment]) -> Result<()> {
138 for fragment in fragments {
140 for data_file in &fragment.files {
141 if data_file.fields.iter().len() == 0 {
142 return Err(Error::invalid_input(format!(
143 "Datafile {} does not contain any fields",
144 data_file.path
145 )));
146 }
147 }
148 }
149 Ok(())
150}
151
152pub fn schema_fragments_legacy_valid(schema: &Schema, fragments: &[Fragment]) -> Result<()> {
156 for fragment in fragments {
159 for field in schema.fields_pre_order() {
160 if !fragment
161 .files
162 .iter()
163 .flat_map(|f| f.fields.iter())
164 .any(|f_id| f_id == &field.id)
165 {
166 return Err(Error::invalid_input(format!(
167 "Fragment {} does not contain field {:?}",
168 fragment.id, field
169 )));
170 }
171 }
172 }
173 Ok(())
174}
175
176#[inline]
184pub(super) fn merge_fragment_physically_rewritten(prev: &Fragment, merged: &Fragment) -> bool {
185 debug_assert_eq!(prev.id, merged.id);
186 if prev.files.len() != merged.files.len() {
187 return true;
188 }
189 prev.files.iter().zip(merged.files.iter()).any(|(p, m)| {
193 p.path != m.path
194 || p.fields != m.fields
195 || p.column_indices != m.column_indices
196 || p.file_major_version != m.file_major_version
197 || p.file_minor_version != m.file_minor_version
198 || p.base_id != m.base_id
199 })
200}
201
202fn merge_fragments_valid(manifest: &Manifest, new_fragments: &[Fragment]) -> Result<()> {
206 let original_fragments = manifest.fragments.as_ref();
207
208 if new_fragments.len() < original_fragments.len() {
210 return Err(Error::invalid_input(format!(
211 "Merge operation reduced fragment count from {} to {}. \
212 Merge operations should only add columns, not reduce fragments.",
213 original_fragments.len(),
214 new_fragments.len()
215 )));
216 }
217
218 let new_fragment_map: HashMap<u64, &Fragment> =
220 new_fragments.iter().map(|f| (f.id, f)).collect();
221
222 let mut missing_fragments: Vec<u64> = Vec::new();
225 for original_fragment in original_fragments {
226 if let Some(new_fragment) = new_fragment_map.get(&original_fragment.id) {
227 if original_fragment.physical_rows != new_fragment.physical_rows {
229 return Err(Error::invalid_input(format!(
230 "Merge operation changed row count for fragment {}. \
231 Original: {:?}, New: {:?}. \
232 Merge operations should preserve fragment row counts and only add new columns.",
233 original_fragment.id,
234 original_fragment.physical_rows,
235 new_fragment.physical_rows
236 )));
237 }
238 } else {
239 missing_fragments.push(original_fragment.id);
240 }
241 }
242
243 if !missing_fragments.is_empty() {
244 return Err(Error::invalid_input(format!(
245 "Merge operation is missing original fragments: {:?}. \
246 Merge operations should preserve all original fragments and only add new columns. \
247 Expected fragments: {:?}, but got: {:?}",
248 missing_fragments,
249 original_fragments.iter().map(|f| f.id).collect::<Vec<_>>(),
250 new_fragment_map.keys().copied().collect::<Vec<_>>()
251 )));
252 }
253
254 Ok(())
255}
256
257fn merge_schema_valid(
270 manifest: &Manifest,
271 new_schema: &Schema,
272 fragments: &[Fragment],
273) -> Result<()> {
274 let prior_schema = &manifest.schema;
275 let new_fragment_map: HashMap<u64, &Fragment> = fragments
276 .iter()
277 .map(|fragment| (fragment.id, fragment))
278 .collect();
279
280 for field in new_schema.fields_pre_order() {
283 let Some(prior_field) = prior_schema.field_by_id(field.id) else {
284 continue;
285 };
286 let prior_path = prior_schema.field_path(field.id)?;
287 let new_path = new_schema.field_path(field.id)?;
288 if prior_path != new_path {
289 return Err(Error::invalid_input(format!(
290 "Merge operation remaps field id {} from \"{}\" to \"{}\". \
291 Merge must preserve the dataset's field ids: derive the new schema \
292 from the dataset's current schema instead of renumbering fields.",
293 field.id, prior_path, new_path
294 )));
295 }
296 if let Some(changes) = shared_field_binding_changes(prior_field, field)
297 && !is_field_binding_fully_rewritten(manifest, &new_fragment_map, field.id)
298 {
299 return Err(Error::invalid_input(format!(
300 "Merge operation changes field id {} (\"{}\") without rewriting it in \
301 every existing fragment: {}. Merge must preserve each existing field's \
302 logical type, nullability, storage encoding, and dictionary unless all \
303 existing base and overlay files carrying that field are replaced.",
304 field.id, new_path, changes
305 )));
306 }
307 }
308
309 let max_field_id = manifest.max_field_id();
310 for field in new_schema.fields_pre_order() {
311 if prior_schema.field_by_id(field.id).is_none() && field.id <= max_field_id {
312 let next_id_msg = match max_field_id.checked_add(1) {
313 Some(next_id) => format!("New fields must use ids of at least {}.", next_id),
314 None => {
315 "No further field id can be allocated because ids are exhausted.".to_string()
316 }
317 };
318 return Err(Error::invalid_input(format!(
319 "Merge operation assigns id {} to new field \"{}\", but ids up to {} are \
320 already used by current or dropped fields. {}",
321 field.id,
322 new_schema.field_path(field.id)?,
323 max_field_id,
324 next_id_msg
325 )));
326 }
327 }
328
329 let mut prior_paths = HashMap::with_capacity(prior_schema.fields_pre_order().count());
330 for field in prior_schema.fields_pre_order() {
331 prior_paths.insert(prior_schema.field_path(field.id)?, field);
332 }
333 for field in new_schema.fields_pre_order() {
334 if prior_schema.field_by_id(field.id).is_some() {
335 continue;
336 }
337 let new_path = new_schema.field_path(field.id)?;
338 let Some(prior_field) = prior_paths.get(&new_path) else {
339 continue;
340 };
341 let materialized = fragments.iter().all(|fragment| {
342 fragment
343 .files
344 .iter()
345 .any(|file| file.fields.contains(&field.id))
346 });
347 if !materialized {
348 return Err(Error::invalid_input(format!(
349 "Merge operation remaps existing field \"{}\" from id {} to id {} without \
350 rewriting its data. Every proposed fragment must materialize the new field \
351 id in a base data file.",
352 new_path, prior_field.id, field.id
353 )));
354 }
355 }
356
357 Ok(())
358}
359
360fn is_field_binding_fully_rewritten(
361 manifest: &Manifest,
362 new_fragment_map: &HashMap<u64, &Fragment>,
363 field_id: i32,
364) -> bool {
365 manifest.fragments.iter().all(|prior_fragment| {
366 let Some(new_fragment) = new_fragment_map.get(&prior_fragment.id) else {
367 return false;
368 };
369
370 let is_materialized = new_fragment
371 .files
372 .iter()
373 .any(|file| file.fields.contains(&field_id));
374 if !is_materialized {
375 return false;
376 }
377
378 prior_fragment
379 .referenced_lance_files()
380 .filter(|file| file.fields.contains(&field_id))
381 .all(|prior_file| {
382 !new_fragment.referenced_lance_files().any(|new_file| {
383 new_file.fields.contains(&field_id)
384 && new_file.base_id == prior_file.base_id
385 && new_file.path == prior_file.path
386 })
387 })
388 })
389}
390
391fn shared_field_binding_changes(prior: &Field, new: &Field) -> Option<String> {
392 let mut changes = Vec::with_capacity(4);
393 if prior.logical_type != new.logical_type {
394 changes.push(format!(
395 "logical type {} -> {}",
396 prior.logical_type, new.logical_type
397 ));
398 }
399 if prior.nullable != new.nullable {
400 changes.push(format!("nullable {} -> {}", prior.nullable, new.nullable));
401 }
402 if prior.encoding != new.encoding {
403 changes.push(format!(
404 "storage encoding {:?} -> {:?}",
405 prior.encoding, new.encoding
406 ));
407 }
408 if prior.dictionary != new.dictionary {
409 changes.push("dictionary".to_string());
410 }
411 if changes.is_empty() {
412 None
413 } else {
414 Some(changes.join(", "))
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::format::overlay::{DataOverlayFile, OverlayCoverage};
422 use crate::format::{DataFile, DataStorageFormat};
423 use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema};
424 use lance_core::datatypes::{Field as LanceCoreField, LogicalType, Schema as LanceSchema};
425 use roaring::RoaringBitmap;
426 use std::collections::HashMap;
427 use std::sync::Arc;
428
429 #[test]
430 fn test_merge_fragments_valid() {
431 let schema = ArrowSchema::new(vec![
433 ArrowField::new("id", DataType::Int32, false),
434 ArrowField::new("name", DataType::Utf8, false),
435 ]);
436
437 let original_fragments = vec![Fragment::new(1), Fragment::new(2), Fragment::new(3)];
439
440 let manifest = Manifest::new(
442 LanceSchema::try_from(&schema).unwrap(),
443 Arc::new(original_fragments),
444 DataStorageFormat::new(ConcreteFileVersion::V2_0),
445 HashMap::new(),
446 );
447
448 let empty_fragments = vec![];
450 let result = merge_fragments_valid(&manifest, &empty_fragments);
451 assert!(result.is_err());
452 assert!(
453 result
454 .unwrap_err()
455 .to_string()
456 .contains("reduced fragment count")
457 );
458
459 let missing_fragments = vec![
461 Fragment::new(1),
462 Fragment::new(2),
463 Fragment::new(4), ];
466 let result = merge_fragments_valid(&manifest, &missing_fragments);
467 assert!(result.is_err());
468 assert!(
469 result
470 .unwrap_err()
471 .to_string()
472 .contains("missing original fragments")
473 );
474
475 let reduced_fragments = vec![
477 Fragment::new(1),
478 Fragment::new(2),
479 ];
481 let result = merge_fragments_valid(&manifest, &reduced_fragments);
482 assert!(result.is_err());
483 assert!(
484 result
485 .unwrap_err()
486 .to_string()
487 .contains("reduced fragment count")
488 );
489
490 let valid_fragments = vec![
492 Fragment::new(1),
493 Fragment::new(2),
494 Fragment::new(3),
495 Fragment::new(4), Fragment::new(5), ];
498 let result = merge_fragments_valid(&manifest, &valid_fragments);
499 assert!(result.is_ok());
500
501 let same_fragments = vec![Fragment::new(1), Fragment::new(2), Fragment::new(3)];
503 let result = merge_fragments_valid(&manifest, &same_fragments);
504 assert!(result.is_ok());
505 }
506
507 fn one_field_schema() -> LanceSchema {
508 LanceSchema::try_from(&ArrowSchema::new(vec![ArrowField::new(
509 "a",
510 DataType::Int32,
511 true,
512 )]))
513 .unwrap()
514 }
515
516 fn fragment_with_file_fields(id: u64, path: &str, fields: Vec<i32>) -> Fragment {
517 let mut fragment = Fragment::new(id);
518 fragment
519 .files
520 .push(DataFile::new_legacy_from_fields(path, fields, None));
521 fragment
522 }
523
524 fn manifest_with_file_fields(schema: LanceSchema, fields: Vec<i32>) -> Manifest {
525 Manifest::new(
526 schema,
527 Arc::new(vec![fragment_with_file_fields(0, "f.lance", fields)]),
528 DataStorageFormat::new(ConcreteFileVersion::V2_0),
529 HashMap::new(),
530 )
531 }
532
533 #[rstest::rstest]
534 #[case::logical_type(DataType::Float32, true)]
535 #[case::nullability(DataType::Int32, false)]
536 #[test]
537 fn test_merge_shared_id_change_requires_full_rewrite(
538 #[case] data_type: DataType,
539 #[case] nullable: bool,
540 ) {
541 let schema = one_field_schema();
542 let prior_fragments = vec![
543 fragment_with_file_fields(0, "old-0.lance", vec![0]),
544 fragment_with_file_fields(1, "old-1.lance", vec![0]),
545 ];
546 let manifest = Manifest::new(
547 schema.clone(),
548 Arc::new(prior_fragments.clone()),
549 DataStorageFormat::new(ConcreteFileVersion::V2_0),
550 HashMap::new(),
551 );
552 let mut new_schema = schema;
553 new_schema.fields[0].logical_type = LogicalType::try_from(&data_type).unwrap();
554 new_schema.fields[0].nullable = nullable;
555
556 let rewritten_fragments = vec![
557 fragment_with_file_fields(0, "new-0.lance", vec![0]),
558 fragment_with_file_fields(1, "new-1.lance", vec![0]),
559 ];
560 merge_schema_valid(&manifest, &new_schema, &rewritten_fragments).unwrap();
561
562 let partially_rewritten = vec![rewritten_fragments[0].clone(), prior_fragments[1].clone()];
563 let err = merge_schema_valid(&manifest, &new_schema, &partially_rewritten).unwrap_err();
564 assert!(matches!(err, Error::InvalidInput { .. }), "got {:?}", err);
565 assert!(
566 err.to_string()
567 .contains("without rewriting it in every existing fragment"),
568 "unexpected error: {}",
569 err
570 );
571 }
572
573 #[test]
574 fn test_merge_shared_id_change_rejects_retained_overlay() {
575 let schema = one_field_schema();
576 let mut prior_fragment = fragment_with_file_fields(0, "old.lance", vec![0]);
577 prior_fragment.overlays.push(DataOverlayFile {
578 data_file: DataFile::new_legacy_from_fields("old-overlay.lance", vec![0], None),
579 coverage: OverlayCoverage::Shared(Arc::new(RoaringBitmap::from_iter([0_u32]))),
580 committed_version: 1,
581 });
582 let manifest = Manifest::new(
583 schema.clone(),
584 Arc::new(vec![prior_fragment.clone()]),
585 DataStorageFormat::new(ConcreteFileVersion::V2_0),
586 HashMap::new(),
587 );
588 let mut new_schema = schema;
589 new_schema.fields[0].nullable = false;
590
591 let mut rewritten = fragment_with_file_fields(0, "new.lance", vec![0]);
592 rewritten.overlays = prior_fragment.overlays.clone();
593 let err = merge_schema_valid(&manifest, &new_schema, &[rewritten]).unwrap_err();
594 assert!(matches!(err, Error::InvalidInput { .. }), "got {:?}", err);
595 assert!(
596 err.to_string()
597 .contains("without rewriting it in every existing fragment"),
598 "unexpected error: {}",
599 err
600 );
601 }
602
603 #[test]
604 fn test_merge_allows_rewritten_fresh_field_id() {
605 let schema = one_field_schema();
606 let manifest = manifest_with_file_fields(schema.clone(), vec![0]);
607 let mut rewritten_schema = schema;
608 rewritten_schema.fields[0].id = 1;
609 let mut rewritten = manifest.fragments[0].clone();
610 rewritten.files[0] = DataFile::new_legacy_from_fields("rewritten.lance", vec![1], None);
611 merge_schema_valid(&manifest, &rewritten_schema, &[rewritten]).unwrap();
612 }
613
614 #[test]
615 fn test_merge_rejects_max_field_id_overflow() {
616 let schema = one_field_schema();
617 let manifest = manifest_with_file_fields(schema.clone(), vec![0, i32::MAX]);
618 assert_eq!(manifest.max_field_id(), i32::MAX);
619
620 let mut new_schema = schema;
621 let mut extra =
622 LanceCoreField::try_from(&ArrowField::new("b", DataType::Int32, true)).unwrap();
623 extra.id = 1;
624 new_schema.fields.push(extra);
625
626 let err = merge_schema_valid(&manifest, &new_schema, &manifest.fragments).unwrap_err();
627 assert!(matches!(err, Error::InvalidInput { .. }), "got {:?}", err);
628 let message = err.to_string();
629 assert!(
630 message.contains("assigns id 1 to new field \"b\"") && message.contains("exhausted"),
631 "unexpected error: {}",
632 message
633 );
634 }
635
636 #[test]
642 fn test_overwrite_legacy_to_stable_with_struct_fields() {
643 use arrow_schema::Fields;
644
645 let arrow_schema = ArrowSchema::new(vec![
648 ArrowField::new("id", DataType::Int32, false),
649 ArrowField::new("name", DataType::Utf8, false),
650 ArrowField::new(
651 "address",
652 DataType::Struct(Fields::from(vec![
653 ArrowField::new("city", DataType::Utf8, false),
654 ArrowField::new("country", DataType::Utf8, false),
655 ])),
656 false,
657 ),
658 ]);
659 let schema = LanceSchema::try_from(&arrow_schema).unwrap();
660
661 let legacy_manifest = Manifest::new(
663 schema.clone(),
664 Arc::new(vec![Fragment::new(0)]),
665 DataStorageFormat::new(ConcreteFileVersion::V1),
666 HashMap::new(),
667 );
668
669 let stable_fragment = Fragment {
672 id: 0,
673 files: vec![DataFile::new(
674 "data.lance",
675 vec![0, 1, 3, 4], vec![0, 1, 2, 3],
677 ConcreteFileVersion::V1,
678 None,
679 None,
680 )],
681 physical_rows: Some(10),
682 overlays: vec![],
683 deletion_file: None,
684 row_id_meta: None,
685 last_updated_at_version_meta: None,
686 created_at_version_meta: None,
687 };
688
689 let operation = Operation::Overwrite {
690 fragments: vec![stable_fragment],
691 schema,
692 config_upsert_values: None,
693 initial_bases: None,
694 };
695
696 validate_operation(Some(&legacy_manifest), &operation).unwrap();
699 }
700}