Skip to main content

lance_table/transaction/
validate.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Pre-commit validation of an operation against the manifest it applies to.
5//!
6//! These checks reject transactions that could not produce a coherent manifest —
7//! a fragment list that disagrees with the schema, a merge that silently dropped
8//! or rewrote data files — before any manifest is written.
9
10use 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
18/// Validate the operation is valid for the given manifest.
19pub 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            // Validate here because we are going to return early.
28            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            // Fragments must contain all fields in the schema
46            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            // Pass None for manifest because Overwrite replaces all fragments.
63            // The old manifest's storage format is irrelevant for validating
64            // the new fragments (e.g., LEGACY→STABLE transitions).
65            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            // Key-presence check only applies to RewriteColumns: that is the only
77            // mode where build_manifest stamps version metadata using off_map keys,
78            // so a stray key can corrupt an unrelated fragment's metadata.
79            // Other modes (e.g. rewrite_rows) may supply offsets for fragments
80            // outside updated_fragments for their own purposes.
81            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
101// An overwrite's fragments are newly written, so they are given fresh ids at
102// commit time. A deletion file cannot come along for that ride: its path embeds
103// the fragment id, so renumbering the fragment would orphan the deletion vector
104// and silently resurrect deleted rows.
105fn 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    // validate that each data file at least contains one field.
139    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
152/// Check that each fragment contains all fields in the schema.
153/// It is not required that the schema contains all fields in the fragment.
154/// There may be masked fields.
155pub fn schema_fragments_legacy_valid(schema: &Schema, fragments: &[Fragment]) -> Result<()> {
156    // TODO: add additional validation. Consider consolidating with various
157    // validate() methods in the codebase.
158    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/// Returns true if Operation::Merge rewrote this fragment's column data files (Fragment::files
177/// changed versus the previous manifest). Used to bump last_updated_at_version_meta only when
178/// new column values were materialized to disk.
179///
180/// Deletion file changes alone are not treated as rewrites: tombstones remove rows but
181/// survivors did not receive new column bytes; stamping last_updated for those rows would be
182/// incorrect for CDF.
183#[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    // Compare identity fields only. file_size_bytes is an AtomicU64 cache that
190    // concurrent scans can populate in place on the manifest's DataFile, so it
191    // must not be part of the rewrite check.
192    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
202/// Validate that Merge operations preserve all original fragments.
203/// Merge operations should only add columns or rows, not reduce fragments.
204/// This ensures fragments correspond at one-to-one with the original fragment list.
205fn merge_fragments_valid(manifest: &Manifest, new_fragments: &[Fragment]) -> Result<()> {
206    let original_fragments = manifest.fragments.as_ref();
207
208    // Additional validation: ensure we're not accidentally reducing the fragment count
209    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    // Collect new fragment IDs
219    let new_fragment_map: HashMap<u64, &Fragment> =
220        new_fragments.iter().map(|f| (f.id, f)).collect();
221
222    // Check that all original fragments are preserved in the new fragments list
223    // Validate that each original fragment's metadata is preserved
224    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            // Validate physical_rows (row count) hasn't changed
228            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
257/// Validate that a Merge schema preserves the dataset's field id bindings.
258///
259/// Readers resolve columns by field id (name -> schema id -> DataFile::fields
260/// position), so renumbered ids silently rebind live columns to other columns'
261/// bytes. Shared ids must keep their field path. Their logical type,
262/// nullability, storage encoding, and dictionary may change only when every
263/// existing base or overlay file carrying the id is replaced and every
264/// proposed fragment materializes the id in a base data file. New ids must
265/// exceed the manifest's max so a dropped field's id is never reused. An
266/// existing path may move to a fresh id only when every proposed fragment
267/// materializes that id in a base data file (the `alter_columns` cast path).
268/// Omitting a field (dropping it) and updating field metadata remain legal.
269fn 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    // Remap and semantic errors first: a renumbered schema usually violates
281    // both the shared-id and new-id clauses.
282    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        // Create a simple schema for testing
432        let schema = ArrowSchema::new(vec![
433            ArrowField::new("id", DataType::Int32, false),
434            ArrowField::new("name", DataType::Utf8, false),
435        ]);
436
437        // Create original fragments
438        let original_fragments = vec![Fragment::new(1), Fragment::new(2), Fragment::new(3)];
439
440        // Create a manifest with original fragments
441        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        // Test 1: Empty fragments should fail
449        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        // Test 2: Missing original fragments should fail
460        let missing_fragments = vec![
461            Fragment::new(1),
462            Fragment::new(2),
463            // Fragment 3 is missing
464            Fragment::new(4), // New fragment
465        ];
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        // Test 3: Reduced fragment count should fail
476        let reduced_fragments = vec![
477            Fragment::new(1),
478            Fragment::new(2),
479            // Fragment 3 is missing, no new fragments added
480        ];
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        // Test 4: Valid merge with all original fragments plus new ones should succeed
491        let valid_fragments = vec![
492            Fragment::new(1),
493            Fragment::new(2),
494            Fragment::new(3),
495            Fragment::new(4), // New fragment
496            Fragment::new(5), // Another new fragment
497        ];
498        let result = merge_fragments_valid(&manifest, &valid_fragments);
499        assert!(result.is_ok());
500
501        // Test 5: Same fragments (no new ones) should succeed
502        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    /// Regression test for https://github.com/lance-format/lance/issues/6417
637    ///
638    /// When overwriting a LEGACY dataset with STABLE-format fragments, the
639    /// validation should not use the old manifest's format. STABLE fragments
640    /// omit struct parent fields, which the strict legacy check rejects.
641    #[test]
642    fn test_overwrite_legacy_to_stable_with_struct_fields() {
643        use arrow_schema::Fields;
644
645        // Schema: id (field 0), name (field 1), address (field 2, struct parent),
646        //   city (field 3), country (field 4)
647        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        // Old manifest is LEGACY format
662        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        // New fragments in STABLE format omit struct parent field (id=2),
670        // only including leaf fields: id=0, name=1, city=3, country=4
671        let stable_fragment = Fragment {
672            id: 0,
673            files: vec![DataFile::new(
674                "data.lance",
675                vec![0, 1, 3, 4], // no field 2 (struct parent)
676                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        // This should succeed — the old manifest's LEGACY format should not
697        // cause strict validation of the new STABLE fragments.
698        validate_operation(Some(&legacy_manifest), &operation).unwrap();
699    }
700}