Skip to main content

arrow_avro/reader/async_reader/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Asynchronous implementation of Avro file reader.
19//!
20//! This module provides [`AsyncAvroFileReader`], which supports reading and decoding
21//! the Avro OCF format from any source that implements [`AsyncFileReader`].
22
23use crate::compression::CompressionCodec;
24use crate::reader::Decoder;
25use crate::reader::block::{BlockDecoder, BlockDecoderState};
26use arrow_array::RecordBatch;
27use arrow_schema::{ArrowError, SchemaRef};
28use bytes::Bytes;
29use futures::future::BoxFuture;
30use futures::{FutureExt, Stream};
31use std::mem;
32use std::ops::Range;
33use std::pin::Pin;
34use std::task::{Context, Poll};
35
36mod async_file_reader;
37mod builder;
38mod spawn;
39
40pub use async_file_reader::AsyncFileReader;
41pub use builder::{ReaderBuilder, read_header_info};
42pub use spawn::SpawnedReader;
43
44#[cfg(feature = "object_store")]
45mod store;
46
47use crate::errors::AvroError;
48#[allow(deprecated)]
49#[cfg(feature = "object_store")]
50pub use store::AvroObjectReader;
51
52enum FetchNextBehaviour {
53    /// Initial read: scan for sync marker, then move to decoding blocks
54    ReadSyncMarker,
55    /// Parse VLQ header bytes one at a time until Data state, then continue decoding
56    DecodeVLQHeader,
57    /// Continue decoding the current block with the fetched data
58    ContinueDecoding,
59}
60
61enum ReaderState<R> {
62    /// Intermediate state to fix ownership issues
63    InvalidState,
64    /// Initial state, fetch initial range
65    Idle { reader: R },
66    /// Fetching data from the reader
67    FetchingData {
68        future: BoxFuture<'static, Result<(R, Bytes), AvroError>>,
69        next_behaviour: FetchNextBehaviour,
70    },
71    /// Decode a block in a loop until completion
72    DecodingBlock { data: Bytes, reader: R },
73    /// Output batches from a decoded block
74    ReadingBatches {
75        data: Bytes,
76        block_data: Bytes,
77        remaining_in_block: usize,
78        reader: R,
79    },
80    /// Successfully finished reading file contents; drain any remaining buffered records
81    /// from the decoder into (possibly partial) output batches.
82    Flushing,
83    /// Done, flush decoder and return
84    Finished,
85}
86
87/// An asynchronous Avro file reader that implements `Stream<Item = Result<RecordBatch, ArrowError>>`.
88/// This uses an [`AsyncFileReader`] to fetch data ranges as needed, starting with fetching the header,
89/// then reading all the blocks in the provided range where:
90/// 1. Reads and decodes data until the header is fully decoded.
91/// 2. Searching from `range.start` for the first sync marker, and starting with the following block.
92///    (If `range.start` is less than the header length, we start at the header length minus the sync marker bytes)
93/// 3. Reading blocks sequentially, decoding them into RecordBatches.
94/// 4. If a block is incomplete (due to range ending mid-block), fetching the remaining bytes from the [`AsyncFileReader`].
95/// 5. If no range was originally provided, reads the full file.
96/// 6. If the range is 0, file_size is 0, or `range.end` is less than the header length, finish immediately.
97///
98/// # Example
99///
100/// ```
101/// #[tokio::main(flavor = "current_thread")]
102/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
103/// use std::io::Cursor;
104/// use std::sync::Arc;
105/// use arrow_array::{ArrayRef, Int32Array, RecordBatch};
106/// use arrow_schema::{DataType, Field, Schema};
107/// use arrow_avro::reader::AsyncAvroFileReader;
108/// use arrow_avro::writer::AvroWriter;
109/// use futures::TryStreamExt;
110///
111/// // Build a minimal Arrow schema and batch
112/// let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
113/// let batch = RecordBatch::try_new(
114///     Arc::new(schema.clone()),
115///     vec![Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef],
116/// )?;
117///
118/// // Write an Avro OCF to memory
119/// let buffer: Vec<u8> = Vec::new();
120/// let mut writer = AvroWriter::new(buffer, schema)?;
121/// writer.write(&batch)?;
122/// writer.finish()?;
123/// let bytes = writer.into_inner();
124///
125/// // Create an async reader from the in-memory bytes
126/// // `tokio::fs::File` also implements `AsyncFileReader` for reading from disk
127/// let file_size = bytes.len();
128/// let cursor = Cursor::new(bytes);
129/// let reader = AsyncAvroFileReader::builder(cursor, file_size as u64, 1024)
130///     .try_build()
131///     .await?;
132///
133/// // Consume the stream of RecordBatches
134/// let batches: Vec<RecordBatch> = reader.try_collect().await?;
135/// assert_eq!(batches.len(), 1);
136/// assert_eq!(batches[0].num_rows(), 3);
137/// Ok(())
138/// }
139/// ```
140pub struct AsyncAvroFileReader<R> {
141    // Members required to fetch data
142    range: Range<u64>,
143    file_size: u64,
144
145    // Members required to actually decode and read data
146    decoder: Decoder,
147    block_decoder: BlockDecoder,
148    codec: Option<CompressionCodec>,
149    sync_marker: [u8; 16],
150
151    // Members keeping the current state of the reader
152    reader_state: ReaderState<R>,
153    finishing_partial_block: bool,
154}
155
156impl<R> AsyncAvroFileReader<R> {
157    /// Returns a builder for a new [`Self`], allowing some optional parameters.
158    pub fn builder(reader: R, file_size: u64, batch_size: usize) -> ReaderBuilder<R> {
159        ReaderBuilder::new(reader, file_size, batch_size)
160    }
161
162    fn new(
163        range: Range<u64>,
164        file_size: u64,
165        decoder: Decoder,
166        codec: Option<CompressionCodec>,
167        sync_marker: [u8; 16],
168        reader_state: ReaderState<R>,
169    ) -> Self {
170        Self {
171            range,
172            file_size,
173
174            decoder,
175            block_decoder: Default::default(),
176            codec,
177            sync_marker,
178
179            reader_state,
180            finishing_partial_block: false,
181        }
182    }
183
184    /// Returns the Arrow schema for batches produced by this reader.
185    ///
186    /// The schema is determined by the writer schema in the file and the reader schema provided to the builder.
187    pub fn schema(&self) -> SchemaRef {
188        self.decoder.schema()
189    }
190
191    /// Calculate the byte range needed to complete the current block.
192    /// Only valid when block_decoder is in Data or Sync state.
193    /// Returns the range to fetch, or an error if EOF would be reached.
194    fn remaining_block_range(&self) -> Result<Range<u64>, AvroError> {
195        let remaining = self.block_decoder.bytes_remaining() as u64
196            + match self.block_decoder.state() {
197                BlockDecoderState::Data => 16, // Include sync marker
198                BlockDecoderState::Sync => 0,
199                state => {
200                    return Err(AvroError::General(format!(
201                        "remaining_block_range called in unexpected state: {state:?}"
202                    )));
203                }
204            };
205
206        let fetch_end = self.range.end + remaining;
207        if fetch_end > self.file_size {
208            return Err(AvroError::EOF(
209                "Avro block requires more bytes than what exists in the file".into(),
210            ));
211        }
212
213        Ok(self.range.end..fetch_end)
214    }
215
216    /// Terminate the stream after returning this error once.
217    #[inline]
218    fn finish_with_error(
219        &mut self,
220        error: AvroError,
221    ) -> Poll<Option<Result<RecordBatch, AvroError>>> {
222        self.reader_state = ReaderState::Finished;
223        Poll::Ready(Some(Err(error)))
224    }
225
226    #[inline]
227    fn start_flushing(&mut self) {
228        self.reader_state = ReaderState::Flushing;
229    }
230
231    /// Drain any remaining buffered records from the decoder.
232    #[inline]
233    fn poll_flush(&mut self) -> Poll<Option<Result<RecordBatch, AvroError>>> {
234        match self.decoder.flush_block() {
235            Ok(Some(batch)) => {
236                self.reader_state = ReaderState::Flushing;
237                Poll::Ready(Some(Ok(batch)))
238            }
239            Ok(None) => {
240                self.reader_state = ReaderState::Finished;
241                Poll::Ready(None)
242            }
243            Err(e) => self.finish_with_error(e),
244        }
245    }
246}
247
248impl<R: AsyncFileReader + Unpin + 'static> AsyncAvroFileReader<R> {
249    // The forbid question mark thing shouldn't apply here, as it is within the future,
250    // so exported this to a separate function.
251    async fn fetch_bytes(mut reader: R, range: Range<u64>) -> Result<(R, Bytes), AvroError> {
252        let data = reader.get_bytes(range).await?;
253        Ok((reader, data))
254    }
255
256    #[forbid(clippy::question_mark_used)]
257    fn read_next(&mut self, cx: &mut Context<'_>) -> Poll<Option<Result<RecordBatch, AvroError>>> {
258        loop {
259            match mem::replace(&mut self.reader_state, ReaderState::InvalidState) {
260                ReaderState::Idle { reader } => {
261                    let range = self.range.clone();
262                    if range.start >= range.end {
263                        return self.finish_with_error(AvroError::InvalidArgument(format!(
264                            "Invalid range specified for Avro file: start {} >= end {}, file_size: {}",
265                            range.start, range.end, self.file_size
266                        )));
267                    }
268
269                    let future = Self::fetch_bytes(reader, range).boxed();
270                    self.reader_state = ReaderState::FetchingData {
271                        future,
272                        next_behaviour: FetchNextBehaviour::ReadSyncMarker,
273                    };
274                }
275                ReaderState::FetchingData {
276                    mut future,
277                    next_behaviour,
278                } => {
279                    let (reader, data_chunk) = match future.poll_unpin(cx) {
280                        Poll::Ready(Ok(data)) => data,
281                        Poll::Ready(Err(e)) => return self.finish_with_error(e),
282                        Poll::Pending => {
283                            self.reader_state = ReaderState::FetchingData {
284                                future,
285                                next_behaviour,
286                            };
287                            return Poll::Pending;
288                        }
289                    };
290
291                    match next_behaviour {
292                        FetchNextBehaviour::ReadSyncMarker => {
293                            let sync_marker_pos = data_chunk
294                                .windows(16)
295                                .position(|slice| slice == self.sync_marker);
296                            let block_start = match sync_marker_pos {
297                                Some(pos) => pos + 16, // Move past the sync marker
298                                None => {
299                                    // Sync marker not found, valid if we arbitrarily split the file at its end.
300                                    self.reader_state = ReaderState::Finished;
301                                    return Poll::Ready(None);
302                                }
303                            };
304
305                            self.reader_state = ReaderState::DecodingBlock {
306                                reader,
307                                data: data_chunk.slice(block_start..),
308                            };
309                        }
310                        FetchNextBehaviour::DecodeVLQHeader => {
311                            let mut data = data_chunk;
312
313                            // Feed bytes one at a time until we reach Data state (VLQ header complete)
314                            while !matches!(self.block_decoder.state(), BlockDecoderState::Data) {
315                                if data.is_empty() {
316                                    return self.finish_with_error(AvroError::EOF(
317                                        "Unexpected EOF while reading Avro block header".into(),
318                                    ));
319                                }
320                                let consumed = match self.block_decoder.decode(&data[..1]) {
321                                    Ok(consumed) => consumed,
322                                    Err(e) => return self.finish_with_error(e),
323                                };
324                                if consumed == 0 {
325                                    return self.finish_with_error(AvroError::General(
326                                        "BlockDecoder failed to consume byte during VLQ header parsing"
327                                            .into(),
328                                    ));
329                                }
330                                data = data.slice(consumed..);
331                            }
332
333                            // Now we know the block size. Slice remaining data to what we need.
334                            let bytes_remaining = self.block_decoder.bytes_remaining();
335                            let data_to_use = data.slice(..data.len().min(bytes_remaining));
336                            let consumed = match self.block_decoder.decode(&data_to_use) {
337                                Ok(consumed) => consumed,
338                                Err(e) => return self.finish_with_error(e),
339                            };
340                            if consumed != data_to_use.len() {
341                                return self.finish_with_error(AvroError::General(
342                                    "BlockDecoder failed to consume all bytes after VLQ header parsing"
343                                        .into(),
344                                ));
345                            }
346
347                            // May need more data to finish the block.
348                            let range_to_fetch = match self.remaining_block_range() {
349                                Ok(range) if range.is_empty() => {
350                                    // All bytes fetched, move to decoding block directly
351                                    self.reader_state = ReaderState::DecodingBlock {
352                                        reader,
353                                        data: Bytes::new(),
354                                    };
355                                    continue;
356                                }
357                                Ok(range) => range,
358                                Err(e) => return self.finish_with_error(e),
359                            };
360
361                            let future = Self::fetch_bytes(reader, range_to_fetch).boxed();
362                            self.reader_state = ReaderState::FetchingData {
363                                future,
364                                next_behaviour: FetchNextBehaviour::ContinueDecoding,
365                            };
366                            continue;
367                        }
368                        FetchNextBehaviour::ContinueDecoding => {
369                            self.reader_state = ReaderState::DecodingBlock {
370                                reader,
371                                data: data_chunk,
372                            };
373                        }
374                    }
375                }
376                ReaderState::InvalidState => {
377                    return self.finish_with_error(AvroError::General(
378                        "AsyncAvroFileReader in invalid state".into(),
379                    ));
380                }
381                ReaderState::DecodingBlock { reader, mut data } => {
382                    // Try to decode another block from the buffered reader.
383                    let consumed = match self.block_decoder.decode(&data) {
384                        Ok(consumed) => consumed,
385                        Err(e) => return self.finish_with_error(e),
386                    };
387                    data = data.slice(consumed..);
388
389                    // If we reached the end of the block, flush it, and move to read batches.
390                    if let Some(block) = self.block_decoder.flush() {
391                        // Successfully decoded a block.
392                        let block_count = block.count;
393                        let block_data = Bytes::from_owner(if let Some(ref codec) = self.codec {
394                            match codec.decompress(&block.data) {
395                                Ok(decompressed) => decompressed,
396                                Err(e) => return self.finish_with_error(e),
397                            }
398                        } else {
399                            block.data
400                        });
401
402                        // Since we have an active block, move to reading batches
403                        self.reader_state = ReaderState::ReadingBatches {
404                            reader,
405                            data,
406                            block_data,
407                            remaining_in_block: block_count,
408                        };
409                        continue;
410                    }
411
412                    // data should always be consumed unless Finished, if it wasn't, something went wrong
413                    if !data.is_empty() {
414                        return self.finish_with_error(AvroError::General(
415                            "BlockDecoder failed to make progress decoding Avro block".into(),
416                        ));
417                    }
418
419                    if matches!(self.block_decoder.state(), BlockDecoderState::Finished) {
420                        // We've already flushed, so if no batch was produced, we are simply done.
421                        self.finishing_partial_block = false;
422                        self.start_flushing();
423                        continue;
424                    }
425
426                    // If we've tried the following stage before, and still can't decode,
427                    // this means the file is truncated or corrupted.
428                    if self.finishing_partial_block {
429                        return self.finish_with_error(AvroError::EOF(
430                            "Unexpected EOF while reading last Avro block".into(),
431                        ));
432                    }
433
434                    // Avro splitting case: block is incomplete, we need to:
435                    // 1. Parse the length so we know how much to read
436                    // 2. Fetch more data from the reader
437                    // 3. Create a new block data from the remaining slice and the newly fetched data
438                    // 4. Continue decoding until end of block
439                    self.finishing_partial_block = true;
440
441                    // Mid-block, but we don't know how many bytes are missing yet
442                    if matches!(
443                        self.block_decoder.state(),
444                        BlockDecoderState::Count | BlockDecoderState::Size
445                    ) {
446                        // Max VLQ header is 20 bytes (10 bytes each for count and size).
447                        // Fetch just enough to complete it.
448                        const MAX_VLQ_HEADER_SIZE: u64 = 20;
449                        let fetch_end = (self.range.end + MAX_VLQ_HEADER_SIZE).min(self.file_size);
450
451                        // If there is nothing more to fetch, error out
452                        if fetch_end == self.range.end {
453                            return self.finish_with_error(AvroError::EOF(
454                                "Unexpected EOF while reading Avro block header".into(),
455                            ));
456                        }
457
458                        let range_to_fetch = self.range.end..fetch_end;
459                        self.range.end = fetch_end; // Track that we've fetched these bytes
460
461                        let future = Self::fetch_bytes(reader, range_to_fetch).boxed();
462                        self.reader_state = ReaderState::FetchingData {
463                            future,
464                            next_behaviour: FetchNextBehaviour::DecodeVLQHeader,
465                        };
466                        continue;
467                    }
468
469                    // Otherwise, we're mid-block but know how many bytes are remaining to fetch.
470                    let range_to_fetch = match self.remaining_block_range() {
471                        Ok(range) => range,
472                        Err(e) => return self.finish_with_error(e),
473                    };
474
475                    let future = Self::fetch_bytes(reader, range_to_fetch).boxed();
476                    self.reader_state = ReaderState::FetchingData {
477                        future,
478                        next_behaviour: FetchNextBehaviour::ContinueDecoding,
479                    };
480                    continue;
481                }
482                ReaderState::ReadingBatches {
483                    reader,
484                    data,
485                    mut block_data,
486                    mut remaining_in_block,
487                } => {
488                    let (consumed, records_decoded) =
489                        match self.decoder.decode_block(&block_data, remaining_in_block) {
490                            Ok((consumed, records_decoded)) => (consumed, records_decoded),
491                            Err(e) => return self.finish_with_error(e),
492                        };
493
494                    remaining_in_block -= records_decoded;
495
496                    if remaining_in_block == 0 {
497                        if data.is_empty() {
498                            // No more data to read, drain remaining buffered records
499                            self.start_flushing();
500                        } else {
501                            // Finished this block, move to decode next block in the next iteration
502                            self.reader_state = ReaderState::DecodingBlock { reader, data };
503                        }
504                    } else {
505                        // Still more records to decode in this block, slice the already-read data and stay in this state
506                        block_data = block_data.slice(consumed..);
507                        self.reader_state = ReaderState::ReadingBatches {
508                            reader,
509                            data,
510                            block_data,
511                            remaining_in_block,
512                        };
513                    }
514
515                    // We have a full batch ready, emit it
516                    // (This is not mutually exclusive with the block being finished, so the state change is valid)
517                    if self.decoder.batch_is_full() {
518                        return match self.decoder.flush_block() {
519                            Ok(Some(batch)) => Poll::Ready(Some(Ok(batch))),
520                            Ok(None) => self.finish_with_error(AvroError::General(
521                                "Decoder reported a full batch, but flush returned None".into(),
522                            )),
523                            Err(e) => self.finish_with_error(e),
524                        };
525                    }
526                }
527                ReaderState::Flushing => {
528                    return self.poll_flush();
529                }
530                ReaderState::Finished => {
531                    // Terminal: once finished (including after an error), always yield None
532                    self.reader_state = ReaderState::Finished;
533                    return Poll::Ready(None);
534                }
535            }
536        }
537    }
538}
539
540// To maintain compatibility with the expected stream results in the ecosystem, this returns ArrowError.
541impl<R: AsyncFileReader + Unpin + 'static> Stream for AsyncAvroFileReader<R> {
542    type Item = Result<RecordBatch, ArrowError>;
543
544    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
545        self.read_next(cx).map_err(Into::into)
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::codec::Tz;
553    use crate::schema::{
554        AVRO_NAME_METADATA_KEY, AVRO_NAMESPACE_METADATA_KEY, AvroSchema, SCHEMA_METADATA_KEY,
555    };
556    use arrow_array::cast::AsArray;
557    use arrow_array::types::{Int32Type, Int64Type};
558    use arrow_array::*;
559    use arrow_schema::{DataType, Field, Schema, SchemaRef, TimeUnit};
560    use futures::{StreamExt, TryStreamExt};
561    use object_store::local::LocalFileSystem;
562    use object_store::path::Path;
563    use object_store::{ObjectStore, ObjectStoreExt};
564    use std::collections::HashMap;
565    use std::sync::Arc;
566
567    /// An [`AsyncFileReader`] reading via an [`ObjectStore`], mirroring the
568    /// example on the [`AsyncFileReader`] trait documentation
569    #[derive(Clone, Debug)]
570    struct ObjectStoreReader {
571        store: Arc<dyn ObjectStore>,
572        path: Path,
573    }
574
575    impl ObjectStoreReader {
576        fn new(store: Arc<dyn ObjectStore>, path: Path) -> Self {
577            Self { store, path }
578        }
579    }
580
581    impl AsyncFileReader for ObjectStoreReader {
582        fn get_bytes(&mut self, range: Range<u64>) -> BoxFuture<'_, Result<Bytes, AvroError>> {
583            async move {
584                self.store
585                    .get_range(&self.path, range)
586                    .await
587                    .map_err(|e| AvroError::General(e.to_string()))
588            }
589            .boxed()
590        }
591
592        fn get_byte_ranges(
593            &mut self,
594            ranges: Vec<Range<u64>>,
595        ) -> BoxFuture<'_, Result<Vec<Bytes>, AvroError>> {
596            async move {
597                self.store
598                    .get_ranges(&self.path, &ranges)
599                    .await
600                    .map_err(|e| AvroError::General(e.to_string()))
601            }
602            .boxed()
603        }
604    }
605
606    fn arrow_test_data(file: &str) -> String {
607        let base =
608            std::env::var("ARROW_TEST_DATA").unwrap_or_else(|_| "../testing/data".to_string());
609        format!("{}/{}", base, file)
610    }
611
612    fn get_alltypes_schema() -> SchemaRef {
613        get_alltypes_schema_with_tz("+00:00")
614    }
615
616    fn get_alltypes_schema_with_tz(tz_id: &str) -> SchemaRef {
617        let schema = Schema::new(vec![
618            Field::new("id", DataType::Int32, true),
619            Field::new("bool_col", DataType::Boolean, true),
620            Field::new("tinyint_col", DataType::Int32, true),
621            Field::new("smallint_col", DataType::Int32, true),
622            Field::new("int_col", DataType::Int32, true),
623            Field::new("bigint_col", DataType::Int64, true),
624            Field::new("float_col", DataType::Float32, true),
625            Field::new("double_col", DataType::Float64, true),
626            Field::new("date_string_col", DataType::Binary, true),
627            Field::new("string_col", DataType::Binary, true),
628            Field::new(
629                "timestamp_col",
630                DataType::Timestamp(TimeUnit::Microsecond, Some(tz_id.into())),
631                true,
632            ),
633        ])
634        .with_metadata(HashMap::from([(
635            SCHEMA_METADATA_KEY.into(),
636            r#"{
637    "type": "record",
638    "name": "topLevelRecord",
639    "fields": [
640        {
641            "name": "id",
642            "type": [
643                "int",
644                "null"
645            ]
646        },
647        {
648            "name": "bool_col",
649            "type": [
650                "boolean",
651                "null"
652            ]
653        },
654        {
655            "name": "tinyint_col",
656            "type": [
657                "int",
658                "null"
659            ]
660        },
661        {
662            "name": "smallint_col",
663            "type": [
664                "int",
665                "null"
666            ]
667        },
668        {
669            "name": "int_col",
670            "type": [
671                "int",
672                "null"
673            ]
674        },
675        {
676            "name": "bigint_col",
677            "type": [
678                "long",
679                "null"
680            ]
681        },
682        {
683            "name": "float_col",
684            "type": [
685                "float",
686                "null"
687            ]
688        },
689        {
690            "name": "double_col",
691            "type": [
692                "double",
693                "null"
694            ]
695        },
696        {
697            "name": "date_string_col",
698            "type": [
699                "bytes",
700                "null"
701            ]
702        },
703        {
704            "name": "string_col",
705            "type": [
706                "bytes",
707                "null"
708            ]
709        },
710        {
711            "name": "timestamp_col",
712            "type": [
713                {
714                    "type": "long",
715                    "logicalType": "timestamp-micros"
716                },
717                "null"
718            ]
719        }
720    ]
721}
722"#
723            .into(),
724        )]));
725        Arc::new(schema)
726    }
727
728    fn get_alltypes_with_nulls_schema() -> SchemaRef {
729        let schema = Schema::new(vec![
730            Field::new("string_col", DataType::Binary, true),
731            Field::new("int_col", DataType::Int32, true),
732            Field::new("bool_col", DataType::Boolean, true),
733            Field::new("bigint_col", DataType::Int64, true),
734            Field::new("float_col", DataType::Float32, true),
735            Field::new("double_col", DataType::Float64, true),
736            Field::new("bytes_col", DataType::Binary, true),
737        ])
738        .with_metadata(HashMap::from([(
739            SCHEMA_METADATA_KEY.into(),
740            r#"{
741    "type": "record",
742    "name": "topLevelRecord",
743    "fields": [
744        {
745            "name": "string_col",
746            "type": [
747                "null",
748                "string"
749            ],
750            "default": null
751        },
752        {
753            "name": "int_col",
754            "type": [
755                "null",
756                "int"
757            ],
758            "default": null
759        },
760        {
761            "name": "bool_col",
762            "type": [
763                "null",
764                "boolean"
765            ],
766            "default": null
767        },
768        {
769            "name": "bigint_col",
770            "type": [
771                "null",
772                "long"
773            ],
774            "default": null
775        },
776        {
777            "name": "float_col",
778            "type": [
779                "null",
780                "float"
781            ],
782            "default": null
783        },
784        {
785            "name": "double_col",
786            "type": [
787                "null",
788                "double"
789            ],
790            "default": null
791        },
792        {
793            "name": "bytes_col",
794            "type": [
795                "null",
796                "bytes"
797            ],
798            "default": null
799        }
800    ]
801}"#
802            .into(),
803        )]));
804
805        Arc::new(schema)
806    }
807
808    fn get_nested_records_schema() -> SchemaRef {
809        let schema = Schema::new(vec![
810            Field::new(
811                "f1",
812                DataType::Struct(
813                    vec![
814                        Field::new("f1_1", DataType::Utf8, false),
815                        Field::new("f1_2", DataType::Int32, false),
816                        Field::new(
817                            "f1_3",
818                            DataType::Struct(
819                                vec![Field::new("f1_3_1", DataType::Float64, false)].into(),
820                            ),
821                            false,
822                        )
823                        .with_metadata(HashMap::from([
824                            (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns3".to_owned()),
825                            (AVRO_NAME_METADATA_KEY.to_owned(), "record3".to_owned()),
826                        ])),
827                    ]
828                    .into(),
829                ),
830                false,
831            )
832            .with_metadata(HashMap::from([
833                (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns2".to_owned()),
834                (AVRO_NAME_METADATA_KEY.to_owned(), "record2".to_owned()),
835            ])),
836            Field::new(
837                "f2",
838                DataType::List(Arc::new(
839                    Field::new(
840                        "item",
841                        DataType::Struct(
842                            vec![
843                                Field::new("f2_1", DataType::Boolean, false),
844                                Field::new("f2_2", DataType::Float32, false),
845                            ]
846                            .into(),
847                        ),
848                        false,
849                    )
850                    .with_metadata(HashMap::from([
851                        (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns4".to_owned()),
852                        (AVRO_NAME_METADATA_KEY.to_owned(), "record4".to_owned()),
853                    ])),
854                )),
855                false,
856            ),
857            Field::new(
858                "f3",
859                DataType::Struct(vec![Field::new("f3_1", DataType::Utf8, false)].into()),
860                true,
861            )
862            .with_metadata(HashMap::from([
863                (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns5".to_owned()),
864                (AVRO_NAME_METADATA_KEY.to_owned(), "record5".to_owned()),
865            ])),
866            Field::new(
867                "f4",
868                DataType::List(Arc::new(
869                    Field::new(
870                        "item",
871                        DataType::Struct(vec![Field::new("f4_1", DataType::Int64, false)].into()),
872                        true,
873                    )
874                    .with_metadata(HashMap::from([
875                        (AVRO_NAMESPACE_METADATA_KEY.to_owned(), "ns6".to_owned()),
876                        (AVRO_NAME_METADATA_KEY.to_owned(), "record6".to_owned()),
877                    ])),
878                )),
879                false,
880            ),
881        ])
882        .with_metadata(HashMap::from([(
883            SCHEMA_METADATA_KEY.into(),
884            r#"{
885    "type": "record",
886    "namespace": "ns1",
887    "name": "record1",
888    "fields": [
889        {
890            "name": "f1",
891            "type": {
892                "type": "record",
893                "namespace": "ns2",
894                "name": "record2",
895                "fields": [
896                    {
897                        "name": "f1_1",
898                        "type": "string"
899                    },
900                    {
901                        "name": "f1_2",
902                        "type": "int"
903                    },
904                    {
905                        "name": "f1_3",
906                        "type": {
907                            "type": "record",
908                            "namespace": "ns3",
909                            "name": "record3",
910                            "fields": [
911                                {
912                                    "name": "f1_3_1",
913                                    "type": "double"
914                                }
915                            ]
916                        }
917                    }
918                ]
919            }
920        },
921        {
922            "name": "f2",
923            "type": {
924                "type": "array",
925                "items": {
926                    "type": "record",
927                    "namespace": "ns4",
928                    "name": "record4",
929                    "fields": [
930                        {
931                            "name": "f2_1",
932                            "type": "boolean"
933                        },
934                        {
935                            "name": "f2_2",
936                            "type": "float"
937                        }
938                    ]
939                }
940            }
941        },
942        {
943            "name": "f3",
944            "type": [
945                "null",
946                {
947                    "type": "record",
948                    "namespace": "ns5",
949                    "name": "record5",
950                    "fields": [
951                        {
952                            "name": "f3_1",
953                            "type": "string"
954                        }
955                    ]
956                }
957            ],
958            "default": null
959        },
960        {
961            "name": "f4",
962            "type": {
963                "type": "array",
964                "items": [
965                    "null",
966                    {
967                        "type": "record",
968                        "namespace": "ns6",
969                        "name": "record6",
970                        "fields": [
971                            {
972                                "name": "f4_1",
973                                "type": "long"
974                            }
975                        ]
976                    }
977                ]
978            }
979        }
980    ]
981}
982"#
983            .into(),
984        )]));
985
986        Arc::new(schema)
987    }
988
989    async fn read_async_file(
990        path: &str,
991        batch_size: usize,
992        range: Option<Range<u64>>,
993        schema: Option<SchemaRef>,
994        projection: Option<Vec<usize>>,
995    ) -> Result<Vec<RecordBatch>, ArrowError> {
996        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
997        let location = Path::from_filesystem_path(path).unwrap();
998
999        let file_size = store.head(&location).await.unwrap().size;
1000
1001        let file_reader = ObjectStoreReader::new(store, location);
1002        let mut builder = AsyncAvroFileReader::builder(file_reader, file_size, batch_size);
1003
1004        if let Some(s) = schema {
1005            let reader_schema = AvroSchema::try_from(s.as_ref())?;
1006            builder = builder.with_reader_schema(reader_schema);
1007        }
1008
1009        if let Some(proj) = projection {
1010            builder = builder.with_projection(proj);
1011        }
1012
1013        if let Some(range) = range {
1014            builder = builder.with_range(range);
1015        }
1016
1017        let reader = builder.try_build().await?;
1018        reader.try_collect().await
1019    }
1020
1021    #[tokio::test]
1022    async fn test_full_file_read() {
1023        let file = arrow_test_data("avro/alltypes_plain.avro");
1024        let schema = get_alltypes_schema();
1025        let batches = read_async_file(&file, 1024, None, Some(schema), None)
1026            .await
1027            .unwrap();
1028        let batch = &batches[0];
1029
1030        assert_eq!(batch.num_rows(), 8);
1031        assert_eq!(batch.num_columns(), 11);
1032
1033        let id_array = batch
1034            .column(0)
1035            .as_any()
1036            .downcast_ref::<Int32Array>()
1037            .unwrap();
1038        assert_eq!(id_array.value(0), 4);
1039        assert_eq!(id_array.value(7), 1);
1040    }
1041
1042    #[tokio::test]
1043    async fn test_small_batch_size() {
1044        let file = arrow_test_data("avro/alltypes_plain.avro");
1045        let schema = get_alltypes_schema();
1046        let batches = read_async_file(&file, 2, None, Some(schema), None)
1047            .await
1048            .unwrap();
1049        assert_eq!(batches.len(), 4);
1050
1051        let batch = &batches[0];
1052
1053        assert_eq!(batch.num_rows(), 2);
1054        assert_eq!(batch.num_columns(), 11);
1055    }
1056
1057    #[tokio::test]
1058    async fn test_batch_size_one() {
1059        let file = arrow_test_data("avro/alltypes_plain.avro");
1060        let schema = get_alltypes_schema();
1061        let batches = read_async_file(&file, 1, None, Some(schema), None)
1062            .await
1063            .unwrap();
1064        let batch = &batches[0];
1065
1066        assert_eq!(batches.len(), 8);
1067        assert_eq!(batch.num_rows(), 1);
1068    }
1069
1070    #[tokio::test]
1071    async fn test_batch_size_larger_than_file() {
1072        let file = arrow_test_data("avro/alltypes_plain.avro");
1073        let schema = get_alltypes_schema();
1074        let batches = read_async_file(&file, 10000, None, Some(schema), None)
1075            .await
1076            .unwrap();
1077        let batch = &batches[0];
1078
1079        assert_eq!(batch.num_rows(), 8);
1080    }
1081
1082    #[tokio::test]
1083    async fn test_empty_range() {
1084        let file = arrow_test_data("avro/alltypes_plain.avro");
1085        let range = 100..100;
1086        let schema = get_alltypes_schema();
1087        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1088            .await
1089            .unwrap();
1090        assert_eq!(batches.len(), 0);
1091    }
1092
1093    #[tokio::test]
1094    async fn test_range_starting_at_zero() {
1095        // Tests that range starting at 0 correctly skips header
1096        let file = arrow_test_data("avro/alltypes_plain.avro");
1097        let store = Arc::new(LocalFileSystem::new());
1098        let location = Path::from_filesystem_path(&file).unwrap();
1099        let meta = store.head(&location).await.unwrap();
1100
1101        let range = 0..meta.size;
1102        let schema = get_alltypes_schema();
1103        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1104            .await
1105            .unwrap();
1106        let batch = &batches[0];
1107
1108        assert_eq!(batch.num_rows(), 8);
1109    }
1110
1111    #[tokio::test]
1112    async fn test_range_after_header() {
1113        let file = arrow_test_data("avro/alltypes_plain.avro");
1114        let store = Arc::new(LocalFileSystem::new());
1115        let location = Path::from_filesystem_path(&file).unwrap();
1116        let meta = store.head(&location).await.unwrap();
1117
1118        let range = 100..meta.size;
1119        let schema = get_alltypes_schema();
1120        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1121            .await
1122            .unwrap();
1123        let batch = &batches[0];
1124
1125        assert!(batch.num_rows() > 0);
1126    }
1127
1128    #[tokio::test]
1129    async fn test_range_no_sync_marker() {
1130        // Small range unlikely to contain sync marker
1131        let file = arrow_test_data("avro/alltypes_plain.avro");
1132        let range = 50..150;
1133        let schema = get_alltypes_schema();
1134        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1135            .await
1136            .unwrap();
1137        assert_eq!(batches.len(), 0);
1138    }
1139
1140    #[tokio::test]
1141    async fn test_range_starting_mid_file() {
1142        let file = arrow_test_data("avro/alltypes_plain.avro");
1143
1144        let range = 700..768; // Header ends at 675, so this should be mid-block
1145        let schema = get_alltypes_schema();
1146        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1147            .await
1148            .unwrap();
1149        assert_eq!(batches.len(), 0);
1150    }
1151
1152    #[tokio::test]
1153    async fn test_range_ending_at_file_size() {
1154        let file = arrow_test_data("avro/alltypes_plain.avro");
1155        let store = Arc::new(LocalFileSystem::new());
1156        let location = Path::from_filesystem_path(&file).unwrap();
1157        let meta = store.head(&location).await.unwrap();
1158
1159        let range = 200..meta.size;
1160        let schema = get_alltypes_schema();
1161        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1162            .await
1163            .unwrap();
1164        let batch = &batches[0];
1165
1166        assert_eq!(batch.num_rows(), 8);
1167    }
1168
1169    #[tokio::test]
1170    async fn test_incomplete_block_requires_fetch() {
1171        // Range ends mid-block, should trigger fetching_rem_block logic
1172        let file = arrow_test_data("avro/alltypes_plain.avro");
1173        let range = 0..1200;
1174        let schema = get_alltypes_schema();
1175        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1176            .await
1177            .unwrap();
1178        let batch = &batches[0];
1179
1180        assert_eq!(batch.num_rows(), 8)
1181    }
1182
1183    #[tokio::test]
1184    async fn test_partial_vlq_header_requires_fetch() {
1185        // Range ends mid-VLQ header, triggering the Count|Size partial fetch logic.
1186        let file = arrow_test_data("avro/alltypes_plain.avro");
1187        let range = 16..676; // Header should end at 675
1188        let schema = get_alltypes_schema();
1189        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1190            .await
1191            .unwrap();
1192        let batch = &batches[0];
1193
1194        assert_eq!(batch.num_rows(), 8)
1195    }
1196
1197    #[cfg(feature = "snappy")]
1198    #[tokio::test]
1199    async fn test_snappy_compressed_with_range() {
1200        {
1201            let file = arrow_test_data("avro/alltypes_plain.snappy.avro");
1202            let store = Arc::new(LocalFileSystem::new());
1203            let location = Path::from_filesystem_path(&file).unwrap();
1204            let meta = store.head(&location).await.unwrap();
1205
1206            let range = 200..meta.size;
1207            let schema = get_alltypes_schema();
1208            let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1209                .await
1210                .unwrap();
1211            let batch = &batches[0];
1212
1213            assert!(batch.num_rows() > 0);
1214        }
1215    }
1216
1217    #[tokio::test]
1218    async fn test_nulls() {
1219        let file = arrow_test_data("avro/alltypes_nulls_plain.avro");
1220        let schema = get_alltypes_with_nulls_schema();
1221        let batches = read_async_file(&file, 1024, None, Some(schema), None)
1222            .await
1223            .unwrap();
1224        let batch = &batches[0];
1225
1226        assert_eq!(batch.num_rows(), 1);
1227        for col in batch.columns() {
1228            assert!(col.is_null(0));
1229        }
1230    }
1231
1232    #[tokio::test]
1233    async fn test_nested_records() {
1234        let file = arrow_test_data("avro/nested_records.avro");
1235        let schema = get_nested_records_schema();
1236        let batches = read_async_file(&file, 1024, None, Some(schema), None)
1237            .await
1238            .unwrap();
1239        let batch = &batches[0];
1240
1241        assert_eq!(batch.num_rows(), 2);
1242        assert!(batch.num_columns() > 0);
1243    }
1244
1245    #[tokio::test]
1246    async fn test_stream_produces_multiple_batches() {
1247        let file = arrow_test_data("avro/alltypes_plain.avro");
1248        let store = Arc::new(LocalFileSystem::new());
1249        let location = Path::from_filesystem_path(&file).unwrap();
1250
1251        let file_size = store.head(&location).await.unwrap().size;
1252
1253        let file_reader = ObjectStoreReader::new(store, location);
1254        let schema = get_alltypes_schema();
1255        let reader_schema = AvroSchema::try_from(schema.as_ref()).unwrap();
1256        let reader = AsyncAvroFileReader::builder(
1257            file_reader,
1258            file_size,
1259            2, // Small batch size to force multiple batches
1260        )
1261        .with_reader_schema(reader_schema)
1262        .try_build()
1263        .await
1264        .unwrap();
1265
1266        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1267
1268        assert!(batches.len() > 1);
1269        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1270        assert_eq!(total_rows, 8);
1271    }
1272
1273    #[tokio::test]
1274    async fn test_stream_early_termination() {
1275        let file = arrow_test_data("avro/alltypes_plain.avro");
1276        let store = Arc::new(LocalFileSystem::new());
1277        let location = Path::from_filesystem_path(&file).unwrap();
1278
1279        let file_size = store.head(&location).await.unwrap().size;
1280
1281        let file_reader = ObjectStoreReader::new(store, location);
1282        let schema = get_alltypes_schema();
1283        let reader_schema = AvroSchema::try_from(schema.as_ref()).unwrap();
1284        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1)
1285            .with_reader_schema(reader_schema)
1286            .try_build()
1287            .await
1288            .unwrap();
1289
1290        let first_batch = reader.take(1).try_collect::<Vec<_>>().await.unwrap();
1291
1292        assert_eq!(first_batch.len(), 1);
1293        assert!(first_batch[0].num_rows() > 0);
1294    }
1295
1296    #[tokio::test]
1297    async fn test_various_batch_sizes() {
1298        let file = arrow_test_data("avro/alltypes_plain.avro");
1299
1300        for batch_size in [1, 2, 3, 5, 7, 11, 100] {
1301            let schema = get_alltypes_schema();
1302            let batches = read_async_file(&file, batch_size, None, Some(schema), None)
1303                .await
1304                .unwrap();
1305            let batch = &batches[0];
1306
1307            // Size should be what was provided, to the limit of the batch in the file
1308            assert_eq!(
1309                batch.num_rows(),
1310                batch_size.min(8),
1311                "Failed with batch_size={}",
1312                batch_size
1313            );
1314        }
1315    }
1316
1317    #[tokio::test]
1318    async fn test_range_larger_than_file() {
1319        let file = arrow_test_data("avro/alltypes_plain.avro");
1320        let store = Arc::new(LocalFileSystem::new());
1321        let location = Path::from_filesystem_path(&file).unwrap();
1322        let meta = store.head(&location).await.unwrap();
1323
1324        // Range extends beyond file size
1325        let range = 100..(meta.size + 1000);
1326        let schema = get_alltypes_schema();
1327        let batches = read_async_file(&file, 1024, Some(range), Some(schema), None)
1328            .await
1329            .unwrap();
1330        let batch = &batches[0];
1331
1332        // Should clamp to file size
1333        assert_eq!(batch.num_rows(), 8);
1334    }
1335
1336    #[tokio::test]
1337    async fn test_builder_with_header_info() {
1338        let file = arrow_test_data("avro/alltypes_plain.avro");
1339        let store = Arc::new(LocalFileSystem::new());
1340        let location = Path::from_filesystem_path(&file).unwrap();
1341
1342        let file_size = store.head(&location).await.unwrap().size;
1343
1344        let mut file_reader = ObjectStoreReader::new(store, location);
1345
1346        let header_info = read_header_info(&mut file_reader, file_size, None)
1347            .await
1348            .unwrap();
1349
1350        assert_eq!(header_info.header_len(), 675);
1351
1352        let writer_schema = header_info.writer_schema().unwrap();
1353        let expected_avro_json: serde_json::Value = serde_json::from_str(
1354            get_alltypes_schema()
1355                .metadata()
1356                .get(SCHEMA_METADATA_KEY)
1357                .unwrap(),
1358        )
1359        .unwrap();
1360        let actual_avro_json: serde_json::Value =
1361            serde_json::from_str(&writer_schema.json_string).unwrap();
1362        assert_eq!(actual_avro_json, expected_avro_json);
1363
1364        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1365            .build_with_header(header_info)
1366            .unwrap();
1367
1368        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1369
1370        let batch = &batches[0];
1371        assert_eq!(batch.num_rows(), 8)
1372    }
1373
1374    #[tokio::test]
1375    async fn test_roundtrip_write_then_async_read() {
1376        use crate::writer::AvroWriter;
1377        use arrow_array::{Float64Array, StringArray};
1378        use std::fs::File;
1379        use std::io::BufWriter;
1380        use tempfile::tempdir;
1381
1382        // Schema with nullable and non-nullable fields of various types
1383        let schema = Arc::new(Schema::new(vec![
1384            Field::new("id", DataType::Int32, false),
1385            Field::new("name", DataType::Utf8, true),
1386            Field::new("score", DataType::Float64, true),
1387            Field::new("count", DataType::Int64, false),
1388        ]));
1389
1390        let dir = tempdir().unwrap();
1391        let file_path = dir.path().join("roundtrip_test.avro");
1392
1393        // Write multiple batches with nulls
1394        {
1395            let file = File::create(&file_path).unwrap();
1396            let writer = BufWriter::new(file);
1397            let mut avro_writer = AvroWriter::new(writer, schema.as_ref().clone()).unwrap();
1398
1399            // First batch: 3 rows with some nulls
1400            let batch1 = RecordBatch::try_new(
1401                schema.clone(),
1402                vec![
1403                    Arc::new(Int32Array::from(vec![1, 2, 3])),
1404                    Arc::new(StringArray::from(vec![
1405                        Some("alice"),
1406                        None,
1407                        Some("charlie"),
1408                    ])),
1409                    Arc::new(Float64Array::from(vec![Some(95.5), Some(87.3), None])),
1410                    Arc::new(Int64Array::from(vec![10, 20, 30])),
1411                ],
1412            )
1413            .unwrap();
1414            avro_writer.write(&batch1).unwrap();
1415
1416            // Second batch: 2 rows
1417            let batch2 = RecordBatch::try_new(
1418                schema.clone(),
1419                vec![
1420                    Arc::new(Int32Array::from(vec![4, 5])),
1421                    Arc::new(StringArray::from(vec![Some("diana"), Some("eve")])),
1422                    Arc::new(Float64Array::from(vec![None, Some(88.0)])),
1423                    Arc::new(Int64Array::from(vec![40, 50])),
1424                ],
1425            )
1426            .unwrap();
1427            avro_writer.write(&batch2).unwrap();
1428
1429            avro_writer.finish().unwrap();
1430        }
1431
1432        // Read back with small batch size to produce multiple output batches
1433        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1434        let location = Path::from_filesystem_path(&file_path).unwrap();
1435        let file_size = store.head(&location).await.unwrap().size;
1436
1437        let file_reader = ObjectStoreReader::new(store, location);
1438        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 2)
1439            .try_build()
1440            .await
1441            .unwrap();
1442
1443        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1444
1445        // Verify we got multiple output batches due to small batch_size
1446        assert!(
1447            batches.len() > 1,
1448            "Expected multiple batches with batch_size=2"
1449        );
1450
1451        // Verify total row count
1452        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1453        assert_eq!(total_rows, 5);
1454
1455        // Concatenate all batches to verify data
1456        let combined = arrow::compute::concat_batches(&batches[0].schema(), &batches).unwrap();
1457        assert_eq!(combined.num_rows(), 5);
1458        assert_eq!(combined.num_columns(), 4);
1459
1460        // Check id column (non-nullable)
1461        let id_array = combined
1462            .column(0)
1463            .as_any()
1464            .downcast_ref::<Int32Array>()
1465            .unwrap();
1466        assert_eq!(id_array.values(), &[1, 2, 3, 4, 5]);
1467
1468        // Check name column (nullable) - verify nulls are preserved
1469        // Avro strings are read as Binary by default
1470        let name_col = combined.column(1);
1471        let name_array = name_col.as_string::<i32>();
1472        assert_eq!(name_array.value(0), "alice");
1473        assert!(name_col.is_null(1)); // second row has null name
1474        assert_eq!(name_array.value(2), "charlie");
1475
1476        // Check score column (nullable) - verify nulls are preserved
1477        let score_array = combined
1478            .column(2)
1479            .as_any()
1480            .downcast_ref::<Float64Array>()
1481            .unwrap();
1482        assert!(!score_array.is_null(0));
1483        assert!((score_array.value(0) - 95.5).abs() < f64::EPSILON);
1484        assert!(score_array.is_null(2)); // third row has null score
1485        assert!(score_array.is_null(3)); // fourth row has null score
1486        assert!(!score_array.is_null(4));
1487        assert!((score_array.value(4) - 88.0).abs() < f64::EPSILON);
1488
1489        // Check count column (non-nullable)
1490        let count_array = combined
1491            .column(3)
1492            .as_any()
1493            .downcast_ref::<Int64Array>()
1494            .unwrap();
1495        assert_eq!(count_array.values(), &[10, 20, 30, 40, 50]);
1496    }
1497
1498    #[tokio::test]
1499    async fn test_alltypes_no_schema_no_projection() {
1500        // No reader schema, no projection - uses writer schema from file
1501        let file = arrow_test_data("avro/alltypes_plain.avro");
1502        let batches = read_async_file(&file, 1024, None, None, None)
1503            .await
1504            .unwrap();
1505        let batch = &batches[0];
1506
1507        assert_eq!(batch.num_rows(), 8);
1508        assert_eq!(batch.num_columns(), 11);
1509        assert_eq!(batch.schema().field(0).name(), "id");
1510    }
1511
1512    #[tokio::test]
1513    async fn test_alltypes_no_schema_with_projection() {
1514        // No reader schema, with projection - project writer schema
1515        let file = arrow_test_data("avro/alltypes_plain.avro");
1516        // Project [tinyint_col, id, bigint_col] = indices [2, 0, 5]
1517        let batches = read_async_file(&file, 1024, None, None, Some(vec![2, 0, 5]))
1518            .await
1519            .unwrap();
1520        let batch = &batches[0];
1521
1522        assert_eq!(batch.num_rows(), 8);
1523        assert_eq!(batch.num_columns(), 3);
1524        assert_eq!(batch.schema().field(0).name(), "tinyint_col");
1525        assert_eq!(batch.schema().field(1).name(), "id");
1526        assert_eq!(batch.schema().field(2).name(), "bigint_col");
1527
1528        // Verify data values
1529        let tinyint_col = batch.column(0).as_primitive::<Int32Type>();
1530        assert_eq!(tinyint_col.values(), &[0, 1, 0, 1, 0, 1, 0, 1]);
1531
1532        let id = batch.column(1).as_primitive::<Int32Type>();
1533        assert_eq!(id.values(), &[4, 5, 6, 7, 2, 3, 0, 1]);
1534
1535        let bigint_col = batch.column(2).as_primitive::<Int64Type>();
1536        assert_eq!(bigint_col.values(), &[0, 10, 0, 10, 0, 10, 0, 10]);
1537    }
1538
1539    #[tokio::test]
1540    async fn test_alltypes_with_schema_no_projection() {
1541        // With reader schema, no projection
1542        let file = arrow_test_data("avro/alltypes_plain.avro");
1543        let schema = get_alltypes_schema();
1544        let batches = read_async_file(&file, 1024, None, Some(schema), None)
1545            .await
1546            .unwrap();
1547        let batch = &batches[0];
1548
1549        assert_eq!(batch.num_rows(), 8);
1550        assert_eq!(batch.num_columns(), 11);
1551    }
1552
1553    #[tokio::test]
1554    async fn test_alltypes_with_schema_with_projection() {
1555        // With reader schema, with projection
1556        let file = arrow_test_data("avro/alltypes_plain.avro");
1557        let schema = get_alltypes_schema();
1558        // Project [bool_col, id] = indices [1, 0]
1559        let batches = read_async_file(&file, 1024, None, Some(schema), Some(vec![1, 0]))
1560            .await
1561            .unwrap();
1562        let batch = &batches[0];
1563
1564        assert_eq!(batch.num_rows(), 8);
1565        assert_eq!(batch.num_columns(), 2);
1566        assert_eq!(batch.schema().field(0).name(), "bool_col");
1567        assert_eq!(batch.schema().field(1).name(), "id");
1568
1569        let bool_col = batch.column(0).as_boolean();
1570        assert!(bool_col.value(0));
1571        assert!(!bool_col.value(1));
1572
1573        let id = batch.column(1).as_primitive::<Int32Type>();
1574        assert_eq!(id.values(), &[4, 5, 6, 7, 2, 3, 0, 1]);
1575    }
1576
1577    #[tokio::test]
1578    async fn test_alltypes_with_empty_schema_large_batch() {
1579        // With an empty reader schema -- should count rows but produce no columns
1580        let file = arrow_test_data("avro/alltypes_plain.avro");
1581        let schema = Arc::new(Schema::new(Vec::<Field>::new()));
1582        let batches = read_async_file(&file, 1024, None, Some(schema), None)
1583            .await
1584            .unwrap();
1585        assert_eq!(batches.len(), 1);
1586        let batch = &batches[0];
1587
1588        assert_eq!(batch.num_rows(), 8);
1589        assert_eq!(batch.num_columns(), 0);
1590    }
1591
1592    #[tokio::test]
1593    async fn test_alltypes_with_empty_schema_small_batch() {
1594        // With an empty reader schema -- should count rows but produce no columns
1595        let file = arrow_test_data("avro/alltypes_plain.avro");
1596        let schema = Arc::new(Schema::new(Vec::<Field>::new()));
1597        let batches = read_async_file(&file, 5, None, Some(schema), None)
1598            .await
1599            .unwrap();
1600
1601        assert_eq!(batches.len(), 2);
1602
1603        assert_eq!(batches[0].num_rows(), 5);
1604        assert_eq!(batches[0].num_columns(), 0);
1605        assert_eq!(batches[1].num_rows(), 3);
1606        assert_eq!(batches[1].num_columns(), 0);
1607    }
1608
1609    #[tokio::test]
1610    async fn test_nested_no_schema_no_projection() {
1611        // No reader schema, no projection
1612        let file = arrow_test_data("avro/nested_records.avro");
1613        let batches = read_async_file(&file, 1024, None, None, None)
1614            .await
1615            .unwrap();
1616        let batch = &batches[0];
1617
1618        assert_eq!(batch.num_rows(), 2);
1619        assert_eq!(batch.num_columns(), 4);
1620        assert_eq!(batch.schema().field(0).name(), "f1");
1621        assert_eq!(batch.schema().field(1).name(), "f2");
1622        assert_eq!(batch.schema().field(2).name(), "f3");
1623        assert_eq!(batch.schema().field(3).name(), "f4");
1624    }
1625
1626    #[tokio::test]
1627    async fn test_nested_no_schema_with_projection() {
1628        // No reader schema, with projection - reorder nested fields
1629        let file = arrow_test_data("avro/nested_records.avro");
1630        // Project [f3, f1] = indices [2, 0]
1631        let batches = read_async_file(&file, 1024, None, None, Some(vec![2, 0]))
1632            .await
1633            .unwrap();
1634        let batch = &batches[0];
1635
1636        assert_eq!(batch.num_rows(), 2);
1637        assert_eq!(batch.num_columns(), 2);
1638        assert_eq!(batch.schema().field(0).name(), "f3");
1639        assert_eq!(batch.schema().field(1).name(), "f1");
1640    }
1641
1642    #[tokio::test]
1643    async fn test_nested_with_schema_no_projection() {
1644        // With reader schema, no projection
1645        let file = arrow_test_data("avro/nested_records.avro");
1646        let schema = get_nested_records_schema();
1647        let batches = read_async_file(&file, 1024, None, Some(schema), None)
1648            .await
1649            .unwrap();
1650        let batch = &batches[0];
1651
1652        assert_eq!(batch.num_rows(), 2);
1653        assert_eq!(batch.num_columns(), 4);
1654    }
1655
1656    #[tokio::test]
1657    async fn test_nested_with_schema_with_projection() {
1658        // With reader schema, with projection
1659        let file = arrow_test_data("avro/nested_records.avro");
1660        let schema = get_nested_records_schema();
1661        // Project [f4, f2, f1] = indices [3, 1, 0]
1662        let batches = read_async_file(&file, 1024, None, Some(schema), Some(vec![3, 1, 0]))
1663            .await
1664            .unwrap();
1665        let batch = &batches[0];
1666
1667        assert_eq!(batch.num_rows(), 2);
1668        assert_eq!(batch.num_columns(), 3);
1669        assert_eq!(batch.schema().field(0).name(), "f4");
1670        assert_eq!(batch.schema().field(1).name(), "f2");
1671        assert_eq!(batch.schema().field(2).name(), "f1");
1672    }
1673
1674    #[tokio::test]
1675    async fn test_nested_with_empty_schema() {
1676        // With an empty reader schema -- should count rows but produce no columns
1677        let file = arrow_test_data("avro/nested_records.avro");
1678        let schema = Arc::new(
1679            Schema::new(Vec::<Field>::new()).with_metadata(HashMap::from([(
1680                SCHEMA_METADATA_KEY.into(),
1681                r#"{
1682                    "type": "record",
1683                    "namespace": "ns1",
1684                    "name": "record1",
1685                    "fields": []
1686                }"#
1687                .to_owned(),
1688            )])),
1689        );
1690        let batches = read_async_file(&file, 1024, None, Some(schema), None)
1691            .await
1692            .unwrap();
1693        let batch = &batches[0];
1694
1695        assert_eq!(batch.num_rows(), 2);
1696        assert_eq!(batch.num_columns(), 0);
1697    }
1698
1699    #[tokio::test]
1700    async fn test_projection_error_out_of_bounds() {
1701        let file = arrow_test_data("avro/alltypes_plain.avro");
1702        // Index 100 is out of bounds for the 11-field schema
1703        let err = read_async_file(&file, 1024, None, None, Some(vec![100]))
1704            .await
1705            .unwrap_err();
1706        assert!(matches!(err, ArrowError::AvroError(_)));
1707        assert!(err.to_string().contains("out of bounds"));
1708    }
1709
1710    #[tokio::test]
1711    async fn test_projection_error_duplicate_index() {
1712        let file = arrow_test_data("avro/alltypes_plain.avro");
1713        // Duplicate index 0
1714        let err = read_async_file(&file, 1024, None, None, Some(vec![0, 0]))
1715            .await
1716            .unwrap_err();
1717        assert!(matches!(err, ArrowError::AvroError(_)));
1718        assert!(err.to_string().contains("Duplicate projection index"));
1719    }
1720
1721    #[tokio::test]
1722    async fn test_arrow_schema_from_reader_no_reader_schema() {
1723        let file = arrow_test_data("avro/alltypes_plain.avro");
1724        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1725        let location = Path::from_filesystem_path(&file).unwrap();
1726        let file_size = store.head(&location).await.unwrap().size;
1727
1728        let file_reader = ObjectStoreReader::new(store, location);
1729        let expected_schema = get_alltypes_schema()
1730            .as_ref()
1731            .clone()
1732            .with_metadata(Default::default());
1733
1734        // Build reader without providing reader schema - should use writer schema from file
1735        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1736            .try_build()
1737            .await
1738            .unwrap();
1739
1740        assert_eq!(reader.schema().as_ref(), &expected_schema);
1741
1742        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1743        let batch = &batches[0];
1744
1745        assert_eq!(batch.schema().as_ref(), &expected_schema);
1746    }
1747
1748    #[tokio::test]
1749    async fn test_arrow_schema_from_reader_with_reader_schema() {
1750        let file = arrow_test_data("avro/alltypes_plain.avro");
1751        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1752        let location = Path::from_filesystem_path(&file).unwrap();
1753        let file_size = store.head(&location).await.unwrap().size;
1754
1755        let file_reader = ObjectStoreReader::new(store, location);
1756        let schema = get_alltypes_schema()
1757            .project(&[0, 1, 7])
1758            .unwrap()
1759            .with_metadata(Default::default());
1760        let reader_schema = AvroSchema::try_from(&schema).unwrap();
1761        let expected_schema = schema.clone();
1762
1763        // Build reader with provided reader schema - must apply the projection
1764        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1765            .with_reader_schema(reader_schema)
1766            .try_build()
1767            .await
1768            .unwrap();
1769
1770        assert_eq!(reader.schema().as_ref(), &expected_schema);
1771
1772        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1773        let batch = &batches[0];
1774
1775        assert_eq!(batch.schema().as_ref(), &expected_schema);
1776    }
1777
1778    #[tokio::test]
1779    async fn test_arrow_schema_from_reader_nested_records() {
1780        let file = arrow_test_data("avro/nested_records.avro");
1781        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1782        let location = Path::from_filesystem_path(&file).unwrap();
1783        let file_size = store.head(&location).await.unwrap().size;
1784
1785        let file_reader = ObjectStoreReader::new(store, location);
1786
1787        // The schema produced by the reader should match the expected schema,
1788        // attaching Avro type name metadata to fields of record and list types.
1789        let expected_schema = get_nested_records_schema()
1790            .as_ref()
1791            .clone()
1792            .with_metadata(Default::default());
1793
1794        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1795            .try_build()
1796            .await
1797            .unwrap();
1798
1799        assert_eq!(reader.schema().as_ref(), &expected_schema);
1800
1801        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1802        let batch = &batches[0];
1803
1804        assert_eq!(batch.schema().as_ref(), &expected_schema);
1805    }
1806
1807    #[tokio::test]
1808    async fn test_with_header_size_hint_small() {
1809        // Use a very small header size hint to force multiple fetches
1810        let file = arrow_test_data("avro/alltypes_plain.avro");
1811        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1812        let location = Path::from_filesystem_path(&file).unwrap();
1813        let file_size = store.head(&location).await.unwrap().size;
1814
1815        let file_reader = ObjectStoreReader::new(store, location);
1816        let schema = get_alltypes_schema();
1817        let reader_schema = AvroSchema::try_from(schema.as_ref()).unwrap();
1818
1819        // Use a tiny header hint (64 bytes) - header is much larger
1820        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1821            .with_reader_schema(reader_schema)
1822            .with_header_size_hint(64)
1823            .try_build()
1824            .await
1825            .unwrap();
1826
1827        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1828        let batch = &batches[0];
1829
1830        assert_eq!(batch.num_rows(), 8);
1831        assert_eq!(batch.num_columns(), 11);
1832    }
1833
1834    #[tokio::test]
1835    async fn test_with_header_size_hint_large() {
1836        // Use a larger header size hint than needed
1837        let file = arrow_test_data("avro/alltypes_plain.avro");
1838        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1839        let location = Path::from_filesystem_path(&file).unwrap();
1840        let file_size = store.head(&location).await.unwrap().size;
1841
1842        let file_reader = ObjectStoreReader::new(store, location);
1843        let schema = get_alltypes_schema();
1844        let reader_schema = AvroSchema::try_from(schema.as_ref()).unwrap();
1845
1846        // Use a large header hint (64KB)
1847        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1848            .with_reader_schema(reader_schema)
1849            .with_header_size_hint(64 * 1024)
1850            .try_build()
1851            .await
1852            .unwrap();
1853
1854        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1855        let batch = &batches[0];
1856
1857        assert_eq!(batch.num_rows(), 8);
1858        assert_eq!(batch.num_columns(), 11);
1859    }
1860
1861    #[tokio::test]
1862    async fn test_with_tz_utc() {
1863        let file = arrow_test_data("avro/alltypes_plain.avro");
1864        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1865        let location = Path::from_filesystem_path(&file).unwrap();
1866        let file_size = store.head(&location).await.unwrap().size;
1867
1868        let file_reader = ObjectStoreReader::new(store, location);
1869        let schema = get_alltypes_schema_with_tz("UTC");
1870        let reader_schema = AvroSchema::try_from(schema.as_ref()).unwrap();
1871
1872        // Specify the time zone ID of "UTC" for timestamp fields with time zone.
1873        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1874            .with_reader_schema(reader_schema)
1875            .with_tz(Tz::Utc)
1876            .try_build()
1877            .await
1878            .unwrap();
1879
1880        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1881        let batch = &batches[0];
1882
1883        assert_eq!(batch.num_columns(), 11);
1884
1885        let schema = batch.schema();
1886        let ts_field = schema.field_with_name("timestamp_col").unwrap();
1887        assert!(
1888            matches!(
1889                ts_field.data_type(),
1890                DataType::Timestamp(TimeUnit::Microsecond, Some(tz)) if tz.as_ref() == "UTC"
1891            ),
1892            "expected Timestamp(Microsecond, Some(\"UTC\")), got {:?}",
1893            ts_field.data_type()
1894        );
1895    }
1896
1897    #[tokio::test]
1898    async fn test_with_utf8_view_enabled() {
1899        // Test that utf8_view produces StringViewArray instead of StringArray
1900        let file = arrow_test_data("avro/nested_records.avro");
1901        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1902        let location = Path::from_filesystem_path(&file).unwrap();
1903        let file_size = store.head(&location).await.unwrap().size;
1904
1905        let file_reader = ObjectStoreReader::new(store, location);
1906
1907        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1908            .with_utf8_view(true)
1909            .try_build()
1910            .await
1911            .unwrap();
1912
1913        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1914        let batch = &batches[0];
1915
1916        assert_eq!(batch.num_rows(), 2);
1917
1918        // The f1 struct contains f1_1 which is a string field
1919        // With utf8_view enabled, it should be Utf8View type
1920        let f1_col = batch.column(0);
1921        let f1_struct = f1_col.as_struct();
1922        let f1_1_field = f1_struct.column_by_name("f1_1").unwrap();
1923
1924        // Check that the data type is Utf8View
1925        assert_eq!(f1_1_field.data_type(), &DataType::Utf8View);
1926    }
1927
1928    #[tokio::test]
1929    async fn test_with_utf8_view_disabled() {
1930        // Test that without utf8_view, we get regular Utf8
1931        let file = arrow_test_data("avro/nested_records.avro");
1932        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1933        let location = Path::from_filesystem_path(&file).unwrap();
1934        let file_size = store.head(&location).await.unwrap().size;
1935
1936        let file_reader = ObjectStoreReader::new(store, location);
1937
1938        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1939            .with_utf8_view(false)
1940            .try_build()
1941            .await
1942            .unwrap();
1943
1944        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1945        let batch = &batches[0];
1946
1947        assert_eq!(batch.num_rows(), 2);
1948
1949        // The f1 struct contains f1_1 which is a string field
1950        // Without utf8_view, it should be regular Utf8
1951        let f1_col = batch.column(0);
1952        let f1_struct = f1_col.as_struct();
1953        let f1_1_field = f1_struct.column_by_name("f1_1").unwrap();
1954
1955        assert_eq!(f1_1_field.data_type(), &DataType::Utf8);
1956    }
1957
1958    #[tokio::test]
1959    async fn test_with_strict_mode_disabled_allows_null_second() {
1960        // Test that with strict_mode disabled, unions of ['T', 'null'] are allowed
1961        // The alltypes_nulls_plain.avro file has unions with null second
1962        let file = arrow_test_data("avro/alltypes_nulls_plain.avro");
1963        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1964        let location = Path::from_filesystem_path(&file).unwrap();
1965        let file_size = store.head(&location).await.unwrap().size;
1966
1967        let file_reader = ObjectStoreReader::new(store, location);
1968
1969        // Without strict mode, this should succeed
1970        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1971            .with_strict_mode(false)
1972            .try_build()
1973            .await
1974            .unwrap();
1975
1976        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
1977        assert_eq!(batches.len(), 1);
1978        assert_eq!(batches[0].num_rows(), 1);
1979    }
1980
1981    #[tokio::test]
1982    async fn test_with_strict_mode_enabled_rejects_null_second() {
1983        // Test that with strict_mode enabled, unions of ['T', 'null'] are rejected
1984        // The alltypes_plain.avro file has unions like ["int", "null"] (null second)
1985        let file = arrow_test_data("avro/alltypes_plain.avro");
1986        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
1987        let location = Path::from_filesystem_path(&file).unwrap();
1988        let file_size = store.head(&location).await.unwrap().size;
1989
1990        let file_reader = ObjectStoreReader::new(store, location);
1991
1992        // With strict mode, this should fail because of ['T', 'null'] unions
1993        let result = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
1994            .with_strict_mode(true)
1995            .try_build()
1996            .await;
1997
1998        match result {
1999            Ok(_) => panic!("Expected error for strict_mode with ['T', 'null'] union"),
2000            Err(err) => {
2001                assert!(
2002                    err.to_string().contains("disallowed in strict_mode"),
2003                    "Expected strict_mode error, got: {}",
2004                    err
2005                );
2006            }
2007        }
2008    }
2009
2010    #[tokio::test]
2011    async fn test_with_strict_mode_enabled_valid_schema() {
2012        // Test that strict_mode works with schemas that have proper ['null', 'T'] unions
2013        // The nested_records.avro file has properly ordered unions
2014        let file = arrow_test_data("avro/nested_records.avro");
2015        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
2016        let location = Path::from_filesystem_path(&file).unwrap();
2017        let file_size = store.head(&location).await.unwrap().size;
2018
2019        let file_reader = ObjectStoreReader::new(store, location);
2020
2021        // With strict mode, properly ordered unions should still work
2022        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 1024)
2023            .with_strict_mode(true)
2024            .try_build()
2025            .await
2026            .unwrap();
2027
2028        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
2029        assert_eq!(batches.len(), 1);
2030        assert_eq!(batches[0].num_rows(), 2);
2031    }
2032
2033    #[tokio::test]
2034    async fn test_builder_options_combined() {
2035        // Test combining multiple builder options
2036        let file = arrow_test_data("avro/nested_records.avro");
2037        let store: Arc<dyn ObjectStore> = Arc::new(LocalFileSystem::new());
2038        let location = Path::from_filesystem_path(&file).unwrap();
2039        let file_size = store.head(&location).await.unwrap().size;
2040
2041        let file_reader = ObjectStoreReader::new(store, location);
2042
2043        let reader = AsyncAvroFileReader::builder(file_reader, file_size, 2)
2044            .with_header_size_hint(128)
2045            .with_utf8_view(true)
2046            .with_strict_mode(true)
2047            .with_projection(vec![0, 2]) // f1 and f3
2048            .try_build()
2049            .await
2050            .unwrap();
2051
2052        let batches: Vec<RecordBatch> = reader.try_collect().await.unwrap();
2053        let batch = &batches[0];
2054
2055        // Should have 2 columns (f1 and f3) due to projection
2056        assert_eq!(batch.num_columns(), 2);
2057        assert_eq!(batch.schema().field(0).name(), "f1");
2058        assert_eq!(batch.schema().field(1).name(), "f3");
2059
2060        // Verify utf8_view is applied
2061        let f1_col = batch.column(0);
2062        let f1_struct = f1_col.as_struct();
2063        let f1_1_field = f1_struct.column_by_name("f1_1").unwrap();
2064        assert_eq!(f1_1_field.data_type(), &DataType::Utf8View);
2065    }
2066}