@@ -536,6 +536,30 @@ fn field_column_shape(field: &Field, is_structural: bool) -> (bool, bool) {
536536 ( contributes, recurse)
537537}
538538
539+ // Count the V2.1 physical columns required to reconstruct a projected field.
540+ // This is the same DFS shape consumed by `ColumnInfoIter`: ordinary structural
541+ // nodes are transparent and leaves contribute columns. Indexed metadata loading
542+ // can therefore compact any ordinary structural projection into 0..N while
543+ // preserving this order.
544+ //
545+ // Blob and packed-struct fields remain unsupported by indexed projection. Their
546+ // opaque decode semantics are handled by the existing full-metadata reader.
547+ fn indexed_projection_column_count ( field : & Field ) -> Option < usize > {
548+ if field. is_blob ( ) || field. is_packed_struct ( ) {
549+ return None ;
550+ }
551+
552+ let ( contributes, recurse) = field_column_shape ( field, true ) ;
553+ let initial = usize:: from ( contributes) ;
554+ if !recurse {
555+ return Some ( initial) ;
556+ }
557+
558+ field. children . iter ( ) . try_fold ( initial, |count, child| {
559+ count. checked_add ( indexed_projection_column_count ( child) ?)
560+ } )
561+ }
562+
539563// Whether a field's children each cover the same rows as the field itself. Struct
540564// children do (one value per parent row), so they must share its length. List,
541565// map, and fixed-size-list items have an independent cardinality (item count, not
@@ -1836,12 +1860,18 @@ impl FileMetadataProvider {
18361860 projection : & ReaderProjection ,
18371861 version : LanceFileVersion ,
18381862 ) -> bool {
1839- version >= LanceFileVersion :: V2_1
1840- && !projection. schema . fields . is_empty ( )
1841- && projection. schema . fields . len ( ) == projection. column_indices . len ( )
1842- && projection. schema . fields . iter ( ) . all ( |field| {
1843- field. children . is_empty ( ) && !field. is_blob ( ) && !field. is_packed_struct ( )
1863+ if version < LanceFileVersion :: V2_1 || projection. schema . fields . is_empty ( ) {
1864+ return false ;
1865+ }
1866+
1867+ projection
1868+ . schema
1869+ . fields
1870+ . iter ( )
1871+ . try_fold ( 0usize , |count, field| {
1872+ count. checked_add ( indexed_projection_column_count ( field) ?)
18441873 } )
1874+ == Some ( projection. column_indices . len ( ) )
18451875 }
18461876
18471877 fn validate_indexed_projection (
@@ -1871,7 +1901,7 @@ impl FileMetadataProvider {
18711901 }
18721902 if !Self :: supports_indexed_projection ( projection, metadata_index. version ) {
18731903 return Err ( Error :: not_supported ( format ! (
1874- "lazy column metadata loading only supports direct V2.1+ top-level physical column projections ; got file version {:?}, {} schema fields, and {} column indices" ,
1904+ "lazy column metadata loading requires a V2.1+ ordinary structural projection without blob or packed-struct fields whose physical- column count matches the projection ; got file version {:?}, {} schema fields, and {} column indices" ,
18751905 metadata_index. version,
18761906 projection. schema. fields. len( ) ,
18771907 projection. column_indices. len( )
@@ -2593,7 +2623,7 @@ mod tests {
25932623 use futures:: { StreamExt , prelude:: stream:: TryStreamExt } ;
25942624 use lance_arrow:: { BLOB_META_KEY , RecordBatchExt } ;
25952625 use lance_core:: { ArrowResult , datatypes:: Schema } ;
2596- use lance_datagen:: { BatchCount , ByteCount , RowCount , array, gen_batch} ;
2626+ use lance_datagen:: { ArrayGeneratorExt , BatchCount , ByteCount , RowCount , array, gen_batch} ;
25972627 use lance_encoding:: {
25982628 decoder:: {
25992629 DecodeBatchScheduler , DecoderPlugins , FilterExpression , ReadBatchTask , decode_batch,
@@ -2660,6 +2690,60 @@ mod tests {
26602690 . await
26612691 }
26622692
2693+ async fn create_wide_fixed_size_list_file ( fs : & FsFixture , num_columns : usize ) -> WrittenFile {
2694+ let data_type =
2695+ DataType :: FixedSizeList ( Arc :: new ( Field :: new ( "item" , DataType :: Float32 , true ) ) , 4 ) ;
2696+ let mut reader = gen_batch ( ) ;
2697+ for column_idx in 0 ..num_columns {
2698+ reader = reader. col (
2699+ format ! ( "c{column_idx}" ) ,
2700+ array:: rand_type ( & data_type) . with_random_nulls ( 0.1 ) ,
2701+ ) ;
2702+ }
2703+ let reader = reader. into_reader_rows ( RowCount :: from ( 64 ) , BatchCount :: from ( 4 ) ) ;
2704+
2705+ write_lance_file (
2706+ reader,
2707+ fs,
2708+ FileWriterOptions {
2709+ format_version : Some ( LanceFileVersion :: V2_1 ) ,
2710+ ..Default :: default ( )
2711+ } ,
2712+ )
2713+ . await
2714+ }
2715+
2716+ async fn create_wide_structural_file ( fs : & FsFixture , num_groups : usize ) -> WrittenFile {
2717+ let struct_type = DataType :: Struct ( Fields :: from ( vec ! [
2718+ Field :: new( "x" , DataType :: Int32 , true ) ,
2719+ Field :: new( "y" , DataType :: Int32 , true ) ,
2720+ ] ) ) ;
2721+ let list_type = DataType :: List ( Arc :: new ( Field :: new ( "item" , DataType :: Int32 , true ) ) ) ;
2722+ let mut reader = gen_batch ( ) ;
2723+ for group_idx in 0 ..num_groups {
2724+ reader = reader
2725+ . col (
2726+ format ! ( "s{group_idx}" ) ,
2727+ array:: rand_type ( & struct_type) . with_random_nulls ( 0.5 ) ,
2728+ )
2729+ . col (
2730+ format ! ( "l{group_idx}" ) ,
2731+ array:: rand_type ( & list_type) . with_random_nulls ( 0.5 ) ,
2732+ ) ;
2733+ }
2734+ let reader = reader. into_reader_rows ( RowCount :: from ( 64 ) , BatchCount :: from ( 4 ) ) ;
2735+
2736+ write_lance_file (
2737+ reader,
2738+ fs,
2739+ FileWriterOptions {
2740+ format_version : Some ( LanceFileVersion :: V2_1 ) ,
2741+ ..Default :: default ( )
2742+ } ,
2743+ )
2744+ . await
2745+ }
2746+
26632747 type Transformer = Box < dyn Fn ( & RecordBatch ) -> RecordBatch > ;
26642748
26652749 async fn verify_expected (
@@ -3157,6 +3241,170 @@ mod tests {
31573241 ) ;
31583242 }
31593243
3244+ async fn assert_lazy_projection_matches_eager_and_reads_metadata_subset (
3245+ fs : & FsFixture ,
3246+ projection : ReaderProjection ,
3247+ shape : & str ,
3248+ ) -> Vec < RecordBatch > {
3249+ let file_scheduler = fs
3250+ . scheduler
3251+ . open_file ( & fs. tmp_path , & CachedFileSize :: unknown ( ) )
3252+ . await
3253+ . unwrap ( ) ;
3254+ let eager_reader = FileReader :: try_open (
3255+ file_scheduler. clone ( ) ,
3256+ None ,
3257+ Arc :: < DecoderPlugins > :: default ( ) ,
3258+ & test_cache ( ) ,
3259+ FileReaderOptions :: default ( ) ,
3260+ )
3261+ . await
3262+ . unwrap ( ) ;
3263+ let expected = eager_reader
3264+ . read_stream_projected (
3265+ lance_io:: ReadBatchParams :: RangeFull ,
3266+ 127 ,
3267+ 16 ,
3268+ projection. clone ( ) ,
3269+ FilterExpression :: no_filter ( ) ,
3270+ )
3271+ . await
3272+ . unwrap ( )
3273+ . try_collect :: < Vec < _ > > ( )
3274+ . await
3275+ . unwrap ( ) ;
3276+
3277+ let cache = test_cache ( ) ;
3278+ let lazy_reader = ProjectedFileReader :: try_open (
3279+ file_scheduler,
3280+ Some ( projection. clone ( ) ) ,
3281+ Arc :: < DecoderPlugins > :: default ( ) ,
3282+ & cache,
3283+ FileReaderOptions :: default ( ) ,
3284+ )
3285+ . await
3286+ . unwrap ( ) ;
3287+ let metadata_index = lazy_reader. metadata_index ( ) . unwrap ( ) ;
3288+ let requested_metadata_bytes = projection
3289+ . column_indices
3290+ . iter ( )
3291+ . map ( |column_index| metadata_index. column_metadata_offsets [ * column_index as usize ] . 1 )
3292+ . sum :: < u64 > ( ) ;
3293+ let total_metadata_bytes = metadata_index
3294+ . column_metadata_offsets
3295+ . iter ( )
3296+ . map ( |( _, length) | * length)
3297+ . sum :: < u64 > ( ) ;
3298+ assert ! ( total_metadata_bytes > requested_metadata_bytes * 8 ) ;
3299+
3300+ fs. object_store . io_stats_incremental ( ) ;
3301+ let tasks = lazy_reader
3302+ . read_tasks (
3303+ lance_io:: ReadBatchParams :: Range ( 0 ..0 ) ,
3304+ 127 ,
3305+ None ,
3306+ FilterExpression :: no_filter ( ) ,
3307+ )
3308+ . await
3309+ . unwrap ( ) ;
3310+ assert ! ( collect_read_tasks( tasks, 1 ) . await . is_empty( ) ) ;
3311+ let metadata_stats = fs. object_store . io_stats_incremental ( ) ;
3312+ assert ! (
3313+ metadata_stats. read_bytes < total_metadata_bytes / 2 ,
3314+ "lazy {shape} read fetched too much metadata: read {} bytes, requested column metadata is {} bytes, total column metadata is {} bytes" ,
3315+ metadata_stats. read_bytes,
3316+ requested_metadata_bytes,
3317+ total_metadata_bytes
3318+ ) ;
3319+
3320+ let tasks = lazy_reader
3321+ . read_tasks (
3322+ lance_io:: ReadBatchParams :: RangeFull ,
3323+ 127 ,
3324+ None ,
3325+ FilterExpression :: no_filter ( ) ,
3326+ )
3327+ . await
3328+ . unwrap ( ) ;
3329+ let actual = collect_read_tasks ( tasks, 16 ) . await ;
3330+ assert_eq ! ( expected, actual) ;
3331+ actual
3332+ }
3333+
3334+ #[ tokio:: test]
3335+ async fn test_lazy_reader_fixed_size_list_projection_matches_eager_reader ( ) {
3336+ let fs = FsFixture :: default ( ) ;
3337+ let written_file = create_wide_fixed_size_list_file ( & fs, 512 ) . await ;
3338+ let projection = ReaderProjection :: from_column_names (
3339+ LanceFileVersion :: V2_1 ,
3340+ & written_file. schema ,
3341+ & [ "c17" , "c509" ] ,
3342+ )
3343+ . unwrap ( ) ;
3344+ assert ! ( ProjectedFileReader :: supports_projection(
3345+ & projection,
3346+ LanceFileVersion :: V2_1
3347+ ) ) ;
3348+ assert ! ( !ProjectedFileReader :: supports_projection(
3349+ & projection,
3350+ LanceFileVersion :: V2_0
3351+ ) ) ;
3352+ assert_lazy_projection_matches_eager_and_reads_metadata_subset (
3353+ & fs,
3354+ projection,
3355+ "fixed-size-list" ,
3356+ )
3357+ . await ;
3358+ }
3359+
3360+ #[ tokio:: test]
3361+ async fn test_lazy_reader_nested_projection_compacts_physical_columns ( ) {
3362+ let fs = FsFixture :: default ( ) ;
3363+ let written_file = create_wide_structural_file ( & fs, 128 ) . await ;
3364+ let projection = ReaderProjection :: from_column_names (
3365+ LanceFileVersion :: V2_1 ,
3366+ & written_file. schema ,
3367+ & [ "s97.y" , "l4" , "s3" ] ,
3368+ )
3369+ . unwrap ( ) ;
3370+
3371+ assert_eq ! (
3372+ projection
3373+ . schema
3374+ . fields
3375+ . iter( )
3376+ . map( |field| field. name. as_str( ) )
3377+ . collect:: <Vec <_>>( ) ,
3378+ vec![ "s97" , "l4" , "s3" ]
3379+ ) ;
3380+ assert_eq ! ( projection. schema. fields[ 0 ] . children. len( ) , 1 ) ;
3381+ assert_eq ! ( projection. schema. fields[ 0 ] . children[ 0 ] . name, "y" ) ;
3382+ assert_eq ! ( projection. schema. fields[ 2 ] . children. len( ) , 2 ) ;
3383+ assert_eq ! ( projection. column_indices. len( ) , 4 ) ;
3384+ assert ! (
3385+ projection
3386+ . column_indices
3387+ . windows( 2 )
3388+ . any( |indices| indices[ 0 ] > indices[ 1 ] ) ,
3389+ "the projection must reorder physical columns to exercise compact remapping"
3390+ ) ;
3391+ assert ! ( ProjectedFileReader :: supports_projection(
3392+ & projection,
3393+ LanceFileVersion :: V2_1
3394+ ) ) ;
3395+ let actual = assert_lazy_projection_matches_eager_and_reads_metadata_subset (
3396+ & fs, projection, "nested" ,
3397+ )
3398+ . await ;
3399+ assert ! (
3400+ actual
3401+ . iter( )
3402+ . flat_map( |batch| batch. columns( ) )
3403+ . any( |column| column. null_count( ) > 0 ) ,
3404+ "the structural projection must exercise nullable arrays"
3405+ ) ;
3406+ }
3407+
31603408 #[ rstest]
31613409 #[ case:: before_metadata_region( 90 , 5 ) ]
31623410 #[ case:: after_metadata_region( 190 , 20 ) ]
@@ -3185,19 +3433,23 @@ mod tests {
31853433 ) ;
31863434 }
31873435
3436+ #[ rstest]
3437+ #[ case:: blob( BLOB_META_KEY ) ]
3438+ #[ case:: packed_struct( "lance-encoding:packed" ) ]
31883439 #[ tokio:: test]
3189- async fn test_lazy_reader_rejects_unsupported_projection ( ) {
3440+ async fn test_lazy_reader_rejects_opaque_projection ( # [ case ] metadata_key : & str ) {
31903441 let fs = FsFixture :: default ( ) ;
31913442 let written_file = create_some_file ( & fs, LanceFileVersion :: V2_1 ) . await ;
31923443
3193- let projection = ReaderProjection :: from_column_names (
3444+ let ordinary_projection = ReaderProjection :: from_column_names (
31943445 LanceFileVersion :: V2_1 ,
31953446 & written_file. schema ,
3196- & [ "location" ] ,
3447+ & [ "location.x " ] ,
31973448 )
31983449 . unwrap ( ) ;
3199- assert ! ( !ProjectedFileReader :: supports_projection(
3200- & projection,
3450+ assert_eq ! ( ordinary_projection. schema. fields[ 0 ] . children. len( ) , 1 ) ;
3451+ assert ! ( ProjectedFileReader :: supports_projection(
3452+ & ordinary_projection,
32013453 LanceFileVersion :: V2_1
32023454 ) ) ;
32033455
@@ -3220,6 +3472,15 @@ mod tests {
32203472 "expected InvalidInput, got {err:?}"
32213473 ) ;
32223474
3475+ let mut projection = ordinary_projection;
3476+ Arc :: make_mut ( & mut projection. schema ) . fields [ 0 ]
3477+ . metadata
3478+ . insert ( metadata_key. to_string ( ) , "true" . to_string ( ) ) ;
3479+ assert ! ( !ProjectedFileReader :: supports_projection(
3480+ & projection,
3481+ LanceFileVersion :: V2_1
3482+ ) ) ;
3483+
32233484 let err = ProjectedFileReader :: try_open (
32243485 file_scheduler,
32253486 Some ( projection) ,
@@ -3231,7 +3492,7 @@ mod tests {
32313492 . unwrap_err ( ) ;
32323493 assert ! (
32333494 matches!( err, lance_core:: Error :: NotSupported { .. } ) ,
3234- "expected NotSupported, got {err:?}"
3495+ "expected NotSupported for {metadata_key} , got {err:?}"
32353496 ) ;
32363497 }
32373498
0 commit comments