Skip to main content

datafusion_datasource_json/
source.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//! Execution plan for reading JSON files (line-delimited and array formats)
19
20use std::io::BufReader;
21use std::pin::Pin;
22use std::sync::Arc;
23use std::task::{Context, Poll};
24
25use crate::file_format::JsonDecoder;
26use crate::utils::{ChannelReader, JsonArrayToNdjsonReader};
27
28use datafusion_common::error::{DataFusionError, Result};
29use datafusion_common::exec_datafusion_err;
30use datafusion_common::tree_node::TreeNodeRecursion;
31use datafusion_common_runtime::{JoinSet, SpawnedTask};
32use datafusion_datasource::boundary_stream::AlignedBoundaryStream;
33use datafusion_datasource::decoder::{DecoderDeserializer, deserialize_stream};
34use datafusion_datasource::file_compression_type::FileCompressionType;
35use datafusion_datasource::file_stream::{FileOpenFuture, FileOpener};
36use datafusion_datasource::projection::{ProjectionOpener, SplitProjection};
37use datafusion_datasource::{ListingTableUrl, PartitionedFile, as_file_source};
38use datafusion_physical_plan::projection::ProjectionExprs;
39use datafusion_physical_plan::{ExecutionPlan, ExecutionPlanProperties};
40
41use arrow::array::RecordBatch;
42use arrow::json::ReaderBuilder;
43use arrow::{datatypes::SchemaRef, json};
44use datafusion_datasource::file::FileSource;
45use datafusion_datasource::file_scan_config::FileScanConfig;
46use datafusion_execution::TaskContext;
47use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet;
48
49use futures::{Stream, StreamExt, TryStreamExt};
50use object_store::buffered::BufWriter;
51use object_store::{GetOptions, GetResultPayload, ObjectStore};
52use tokio::io::AsyncWriteExt;
53use tokio_stream::wrappers::ReceiverStream;
54
55/// Channel buffer size for streaming JSON array processing.
56/// With ~128KB average chunk size, 128 chunks ≈ 16MB buffer.
57const CHANNEL_BUFFER_SIZE: usize = 128;
58
59/// Buffer size for JsonArrayToNdjsonReader (2MB each, 4MB total for input+output)
60const JSON_CONVERTER_BUFFER_SIZE: usize = 2 * 1024 * 1024;
61
62// ============================================================================
63// JsonArrayStream - Custom stream wrapper to hold SpawnedTask handles
64// ============================================================================
65
66/// A stream wrapper that holds SpawnedTask handles to keep them alive
67/// until the stream is fully consumed or dropped.
68///
69/// This ensures cancel-safety: when the stream is dropped, the tasks
70/// are properly aborted via SpawnedTask's Drop implementation.
71struct JsonArrayStream {
72    inner: ReceiverStream<std::result::Result<RecordBatch, arrow::error::ArrowError>>,
73    /// Task that reads from object store and sends bytes to channel.
74    /// Kept alive until stream is consumed or dropped.
75    _read_task: SpawnedTask<()>,
76    /// Task that parses JSON and sends RecordBatches.
77    /// Kept alive until stream is consumed or dropped.
78    _parse_task: SpawnedTask<()>,
79}
80
81impl Stream for JsonArrayStream {
82    type Item = std::result::Result<RecordBatch, arrow::error::ArrowError>;
83
84    fn poll_next(
85        mut self: Pin<&mut Self>,
86        cx: &mut Context<'_>,
87    ) -> Poll<Option<Self::Item>> {
88        Pin::new(&mut self.inner).poll_next(cx)
89    }
90
91    fn size_hint(&self) -> (usize, Option<usize>) {
92        self.inner.size_hint()
93    }
94}
95// ============================================================================
96// JsonOpener and JsonSource
97// ============================================================================
98
99/// A [`FileOpener`] that opens a JSON file and yields a [`FileOpenFuture`]
100pub struct JsonOpener {
101    batch_size: usize,
102    projected_schema: SchemaRef,
103    file_compression_type: FileCompressionType,
104    object_store: Arc<dyn ObjectStore>,
105    /// When `true` (default), expects newline-delimited JSON (NDJSON).
106    /// When `false`, expects JSON array format `[{...}, {...}]`.
107    newline_delimited: bool,
108}
109
110impl JsonOpener {
111    /// Returns a [`JsonOpener`]
112    pub fn new(
113        batch_size: usize,
114        projected_schema: SchemaRef,
115        file_compression_type: FileCompressionType,
116        object_store: Arc<dyn ObjectStore>,
117        newline_delimited: bool,
118    ) -> Self {
119        Self {
120            batch_size,
121            projected_schema,
122            file_compression_type,
123            object_store,
124            newline_delimited,
125        }
126    }
127}
128
129/// JsonSource holds the extra configuration that is necessary for [`JsonOpener`]
130#[derive(Clone)]
131pub struct JsonSource {
132    table_schema: datafusion_datasource::TableSchema,
133    batch_size: Option<usize>,
134    metrics: ExecutionPlanMetricsSet,
135    projection: SplitProjection,
136    /// When `true` (default), expects newline-delimited JSON (NDJSON).
137    /// When `false`, expects JSON array format `[{...}, {...}]`.
138    newline_delimited: bool,
139}
140
141impl JsonSource {
142    /// Initialize a JsonSource with the provided schema
143    pub fn new(table_schema: impl Into<datafusion_datasource::TableSchema>) -> Self {
144        let table_schema = table_schema.into();
145        Self {
146            projection: SplitProjection::unprojected(&table_schema),
147            table_schema,
148            batch_size: None,
149            metrics: ExecutionPlanMetricsSet::new(),
150            newline_delimited: true,
151        }
152    }
153
154    /// Set whether to read as newline-delimited JSON.
155    ///
156    /// When `true` (default), expects newline-delimited format.
157    /// When `false`, expects JSON array format `[{...}, {...}]`.
158    pub fn with_newline_delimited(mut self, newline_delimited: bool) -> Self {
159        self.newline_delimited = newline_delimited;
160        self
161    }
162}
163
164impl From<JsonSource> for Arc<dyn FileSource> {
165    fn from(source: JsonSource) -> Self {
166        as_file_source(source)
167    }
168}
169
170impl FileSource for JsonSource {
171    fn create_file_opener(
172        &self,
173        object_store: Arc<dyn ObjectStore>,
174        base_config: &FileScanConfig,
175        _partition: usize,
176    ) -> Result<Arc<dyn FileOpener>> {
177        // Get the projected file schema for JsonOpener
178        let file_schema = self.table_schema.file_schema();
179        let projected_schema =
180            Arc::new(file_schema.project(&self.projection.file_indices)?);
181
182        let mut opener = Arc::new(JsonOpener {
183            batch_size: self
184                .batch_size
185                .expect("Batch size must set before creating opener"),
186            projected_schema,
187            file_compression_type: base_config.file_compression_type,
188            object_store,
189            newline_delimited: self.newline_delimited,
190        }) as Arc<dyn FileOpener>;
191
192        // Wrap with ProjectionOpener
193        opener = ProjectionOpener::try_new(
194            self.projection.clone(),
195            Arc::clone(&opener),
196            self.table_schema.file_schema(),
197        )?;
198
199        Ok(opener)
200    }
201
202    fn table_schema(&self) -> &datafusion_datasource::TableSchema {
203        &self.table_schema
204    }
205
206    fn with_batch_size(&self, batch_size: usize) -> Arc<dyn FileSource> {
207        let mut conf = self.clone();
208        conf.batch_size = Some(batch_size);
209        Arc::new(conf)
210    }
211
212    fn try_pushdown_projection(
213        &self,
214        projection: &ProjectionExprs,
215    ) -> Result<Option<Arc<dyn FileSource>>> {
216        let mut source = self.clone();
217        let new_projection = self.projection.source.try_merge(projection)?;
218        let split_projection =
219            SplitProjection::new(self.table_schema.file_schema(), &new_projection);
220        source.projection = split_projection;
221        Ok(Some(Arc::new(source)))
222    }
223
224    fn projection(&self) -> Option<&ProjectionExprs> {
225        Some(&self.projection.source)
226    }
227
228    fn metrics(&self) -> &ExecutionPlanMetricsSet {
229        &self.metrics
230    }
231
232    fn file_type(&self) -> &str {
233        "json"
234    }
235
236    fn apply_expressions(
237        &self,
238        f: &mut dyn FnMut(
239            &Arc<dyn datafusion_physical_plan::PhysicalExpr>,
240        ) -> Result<TreeNodeRecursion>,
241    ) -> Result<TreeNodeRecursion> {
242        datafusion_physical_plan::apply_expression_roots(self.projection.source.iter(), f)
243    }
244
245    /// Emit a `JsonScan` node wrapping the shared base config.
246    #[cfg(feature = "proto")]
247    fn try_to_proto(
248        &self,
249        base: &FileScanConfig,
250        ctx: &datafusion_physical_plan::proto::ExecutionPlanEncodeCtx<'_>,
251    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
252        use datafusion_proto_models::protobuf;
253        use protobuf::physical_plan_node::PhysicalPlanType;
254
255        let node = protobuf::JsonScanExecNode {
256            base_conf: Some(base.try_to_proto(ctx)?),
257        };
258        Ok(Some(protobuf::PhysicalPlanNode {
259            physical_plan_type: Some(PhysicalPlanType::JsonScan(node)),
260        }))
261    }
262}
263
264#[cfg(feature = "proto")]
265impl JsonSource {
266    /// Reconstructs a `DataSourceExec` from a protobuf `JsonScan`.
267    ///
268    /// Defaults to newline-delimited JSON because protobuf does not encode the mode.
269    pub fn try_from_proto(
270        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
271        ctx: &datafusion_physical_plan::proto::ExecutionPlanDecodeCtx<'_>,
272    ) -> Result<Arc<dyn ExecutionPlan>> {
273        use datafusion_datasource::file_scan_config::FileScanConfig;
274        use datafusion_datasource::source::DataSourceExec;
275        use datafusion_proto_models::protobuf;
276
277        let scan = match &node.physical_plan_type {
278            Some(protobuf::physical_plan_node::PhysicalPlanType::JsonScan(scan)) => scan,
279            _ => {
280                return datafusion_common::internal_err!(
281                    "PhysicalPlanNode is not a JsonScan"
282                );
283            }
284        };
285
286        let base_conf = scan.base_conf.as_ref().ok_or_else(|| {
287            datafusion_common::internal_datafusion_err!(
288                "JsonScanExecNode is missing required field 'base_conf'"
289            )
290        })?;
291
292        let table_schema = FileScanConfig::parse_table_schema_from_proto(base_conf)?;
293        let source = Arc::new(JsonSource::new(table_schema));
294
295        let conf = FileScanConfig::try_from_proto(base_conf, ctx, source)?;
296        Ok(DataSourceExec::from_data_source(conf))
297    }
298}
299
300impl FileOpener for JsonOpener {
301    /// Open a partitioned JSON file.
302    ///
303    /// If `file_meta.range` is `None`, the entire file is opened.
304    /// Else `file_meta.range` is `Some(FileRange{start, end})`, which corresponds to the byte range [start, end) within the file.
305    ///
306    /// Note: `start` or `end` might be in the middle of some lines. In such cases, the following rules
307    /// are applied to determine which lines to read:
308    /// 1. The first line of the partition is the line in which the index of the first character >= `start`.
309    /// 2. The last line of the partition is the line in which the byte at position `end - 1` resides.
310    ///
311    /// Note: JSON array format does not support range-based scanning.
312    fn open(&self, partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
313        let store = Arc::clone(&self.object_store);
314        let schema = Arc::clone(&self.projected_schema);
315        let batch_size = self.batch_size;
316        let file_compression_type = self.file_compression_type.to_owned();
317        let newline_delimited = self.newline_delimited;
318
319        // JSON array format requires reading the complete file
320        if !newline_delimited && partitioned_file.range.is_some() {
321            return Err(DataFusionError::NotImplemented(
322                "JSON array format does not support range-based file scanning. \
323                 Disable repartition_file_scans or use newline-delimited JSON format."
324                    .to_string(),
325            ));
326        }
327
328        Ok(Box::pin(async move {
329            let file_size = partitioned_file.object_meta.size;
330            let location = &partitioned_file.object_meta.location;
331
332            if let Some(file_range) = partitioned_file.range.as_ref() {
333                let raw_start: u64 = file_range.start.try_into().map_err(|_| {
334                    exec_datafusion_err!(
335                        "Expected start range to fit in u64, got {}",
336                        file_range.start
337                    )
338                })?;
339                let raw_end: u64 = file_range.end.try_into().map_err(|_| {
340                    exec_datafusion_err!(
341                        "Expected end range to fit in u64, got {}",
342                        file_range.end
343                    )
344                })?;
345
346                let aligned_stream = AlignedBoundaryStream::new(
347                    Arc::clone(&store),
348                    location.clone(),
349                    raw_start,
350                    raw_end,
351                    file_size,
352                    b'\n',
353                )
354                .await?
355                .map_err(DataFusionError::from);
356
357                let decoder = ReaderBuilder::new(schema)
358                    .with_batch_size(batch_size)
359                    .build_decoder()?;
360                let input = file_compression_type
361                    .convert_stream(aligned_stream.boxed())?
362                    .fuse();
363                let stream = deserialize_stream(
364                    input,
365                    DecoderDeserializer::new(JsonDecoder::new(decoder)),
366                );
367                return Ok(stream.map_err(Into::into).boxed());
368            }
369
370            // No range specified — read the entire file
371            let options = GetOptions::default();
372            let result = store.get_opts(location, options).await?;
373
374            match result.payload {
375                #[cfg(not(target_arch = "wasm32"))]
376                GetResultPayload::File(file, _) => {
377                    let bytes = file_compression_type.convert_read(file)?;
378
379                    if newline_delimited {
380                        // NDJSON: use BufReader directly
381                        let reader = BufReader::new(bytes);
382                        let arrow_reader = ReaderBuilder::new(schema)
383                            .with_batch_size(batch_size)
384                            .build(reader)?;
385
386                        Ok(futures::stream::iter(arrow_reader)
387                            .map(|r| r.map_err(Into::into))
388                            .boxed())
389                    } else {
390                        // JSON array format: wrap with streaming converter
391                        let ndjson_reader = JsonArrayToNdjsonReader::with_capacity(
392                            bytes,
393                            JSON_CONVERTER_BUFFER_SIZE,
394                        );
395                        let arrow_reader = ReaderBuilder::new(schema)
396                            .with_batch_size(batch_size)
397                            .build(ndjson_reader)?;
398
399                        Ok(futures::stream::iter(arrow_reader)
400                            .map(|r| r.map_err(Into::into))
401                            .boxed())
402                    }
403                }
404                GetResultPayload::Stream(s) => {
405                    if newline_delimited {
406                        // Newline-delimited JSON (NDJSON) streaming reader
407                        let s = s.map_err(DataFusionError::from);
408                        let decoder = ReaderBuilder::new(schema)
409                            .with_batch_size(batch_size)
410                            .build_decoder()?;
411                        let input =
412                            file_compression_type.convert_stream(s.boxed())?.fuse();
413                        let stream = deserialize_stream(
414                            input,
415                            DecoderDeserializer::new(JsonDecoder::new(decoder)),
416                        );
417                        Ok(stream.map_err(Into::into).boxed())
418                    } else {
419                        // JSON array format: streaming conversion with channel-based byte transfer
420                        //
421                        // Architecture:
422                        // 1. Async task reads from object store stream, decompresses, sends to channel
423                        // 2. Blocking task receives bytes, converts JSON array to NDJSON, parses to Arrow
424                        // 3. RecordBatches are sent back via another channel
425                        //
426                        // Memory budget (~32MB):
427                        // - sync_channel: CHANNEL_BUFFER_SIZE chunks (~16MB)
428                        // - JsonArrayToNdjsonReader: 2 × JSON_CONVERTER_BUFFER_SIZE (~4MB)
429                        // - Arrow JsonReader internal buffer (~8MB)
430                        // - Miscellaneous (~4MB)
431
432                        let s = s.map_err(DataFusionError::from);
433                        let decompressed_stream =
434                            file_compression_type.convert_stream(s.boxed())?;
435
436                        // Channel for bytes: async producer -> blocking consumer
437                        // Uses tokio::sync::mpsc so the async send never blocks a
438                        // tokio worker thread; the consumer calls blocking_recv()
439                        // inside spawn_blocking.
440                        let (byte_tx, byte_rx) = tokio::sync::mpsc::channel::<bytes::Bytes>(
441                            CHANNEL_BUFFER_SIZE,
442                        );
443
444                        // Channel for results: sync producer -> async consumer
445                        let (result_tx, result_rx) = tokio::sync::mpsc::channel(2);
446                        let error_tx = result_tx.clone();
447
448                        // Async task: read from object store stream and send bytes to channel
449                        // Store the SpawnedTask to keep it alive until stream is dropped
450                        let read_task = SpawnedTask::spawn(async move {
451                            tokio::pin!(decompressed_stream);
452                            while let Some(chunk) = decompressed_stream.next().await {
453                                match chunk {
454                                    Ok(bytes) => {
455                                        if byte_tx.send(bytes).await.is_err() {
456                                            break; // Consumer dropped
457                                        }
458                                    }
459                                    Err(e) => {
460                                        let _ = error_tx
461                                            .send(Err(
462                                                arrow::error::ArrowError::ExternalError(
463                                                    Box::new(e),
464                                                ),
465                                            ))
466                                            .await;
467                                        break;
468                                    }
469                                }
470                            }
471                            // byte_tx dropped here, signals EOF to ChannelReader
472                        });
473
474                        // Blocking task: receive bytes from channel and parse JSON
475                        // Store the SpawnedTask to keep it alive until stream is dropped
476                        let parse_task = SpawnedTask::spawn_blocking(move || {
477                            let channel_reader = ChannelReader::new(byte_rx);
478                            let mut ndjson_reader =
479                                JsonArrayToNdjsonReader::with_capacity(
480                                    channel_reader,
481                                    JSON_CONVERTER_BUFFER_SIZE,
482                                );
483
484                            match ReaderBuilder::new(schema)
485                                .with_batch_size(batch_size)
486                                .build(&mut ndjson_reader)
487                            {
488                                Ok(arrow_reader) => {
489                                    for batch_result in arrow_reader {
490                                        if result_tx.blocking_send(batch_result).is_err()
491                                        {
492                                            break; // Receiver dropped
493                                        }
494                                    }
495                                }
496                                Err(e) => {
497                                    let _ = result_tx.blocking_send(Err(e));
498                                }
499                            }
500
501                            // Validate the JSON array was properly formed
502                            if let Err(e) = ndjson_reader.validate_complete() {
503                                let _ = result_tx.blocking_send(Err(
504                                    arrow::error::ArrowError::JsonError(e.to_string()),
505                                ));
506                            }
507                            // result_tx dropped here, closes the stream
508                        });
509
510                        // Wrap in JsonArrayStream to keep tasks alive until stream is consumed
511                        let stream = JsonArrayStream {
512                            inner: ReceiverStream::new(result_rx),
513                            _read_task: read_task,
514                            _parse_task: parse_task,
515                        };
516
517                        Ok(stream.map(|r| r.map_err(Into::into)).boxed())
518                    }
519                }
520            }
521        }))
522    }
523}
524
525pub async fn plan_to_json(
526    task_ctx: Arc<TaskContext>,
527    plan: Arc<dyn ExecutionPlan>,
528    path: impl AsRef<str>,
529) -> Result<()> {
530    let path = path.as_ref();
531    let parsed = ListingTableUrl::parse(path)?;
532    let object_store_url = parsed.object_store();
533    let store = task_ctx.runtime_env().object_store(&object_store_url)?;
534    let writer_buffer_size = task_ctx
535        .session_config()
536        .options()
537        .execution
538        .objectstore_writer_buffer_size;
539    let mut join_set = JoinSet::new();
540    for i in 0..plan.output_partitioning().partition_count() {
541        let storeref = Arc::clone(&store);
542        let plan: Arc<dyn ExecutionPlan> = Arc::clone(&plan);
543        let filename = format!("{}/part-{i}.json", parsed.prefix());
544        let file = object_store::path::Path::parse(filename)?;
545
546        let mut stream = plan.execute(i, Arc::clone(&task_ctx))?;
547        join_set.spawn(async move {
548            let mut buf_writer =
549                BufWriter::with_capacity(storeref, file.clone(), writer_buffer_size);
550
551            let mut buffer = Vec::with_capacity(1024);
552            while let Some(batch) = stream.next().await.transpose()? {
553                let mut writer = json::LineDelimitedWriter::new(buffer);
554                writer.write(&batch)?;
555                buffer = writer.into_inner();
556                buf_writer.write_all(&buffer).await?;
557                buffer.clear();
558            }
559
560            buf_writer.shutdown().await.map_err(DataFusionError::from)
561        });
562    }
563
564    while let Some(result) = join_set.join_next().await {
565        match result {
566            Ok(res) => res?, // propagate DataFusion error
567            Err(e) => {
568                if e.is_panic() {
569                    std::panic::resume_unwind(e.into_panic());
570                } else {
571                    unreachable!();
572                }
573            }
574        }
575    }
576
577    Ok(())
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::test_utils::{CHUNK_SIZES, make_chunked_store};
584    use arrow::array::{Int64Array, StringArray};
585    use arrow::compute;
586    use arrow::datatypes::{DataType, Field, Schema};
587    use arrow::record_batch::RecordBatch;
588    use bytes::Bytes;
589    use datafusion_datasource::FileRange;
590    use object_store::memory::InMemory;
591    use object_store::path::Path;
592    use object_store::{ObjectStoreExt, PutPayload};
593
594    /// Helper to create a test schema
595    fn test_schema() -> SchemaRef {
596        Arc::new(Schema::new(vec![
597            Field::new("id", DataType::Int64, true),
598            Field::new("name", DataType::Utf8, true),
599        ]))
600    }
601
602    #[tokio::test]
603    async fn test_json_array_from_file() -> Result<()> {
604        // Test reading JSON array format from a file
605        let json_data = r#"[{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}]"#;
606
607        let store = Arc::new(InMemory::new());
608        let path = Path::from("test.json");
609        store
610            .put(&path, PutPayload::from_static(json_data.as_bytes()))
611            .await?;
612
613        let opener = JsonOpener::new(
614            1024,
615            test_schema(),
616            FileCompressionType::UNCOMPRESSED,
617            store.clone(),
618            false, // JSON array format
619        );
620
621        let meta = store.head(&path).await?;
622        let file = PartitionedFile::new(path.to_string(), meta.size);
623
624        let stream = opener.open(file)?.await?;
625        let batches: Vec<_> = stream.try_collect().await?;
626
627        assert_eq!(batches.len(), 1);
628        assert_eq!(batches[0].num_rows(), 2);
629
630        Ok(())
631    }
632
633    #[tokio::test]
634    async fn test_json_array_from_stream() -> Result<()> {
635        // Test reading JSON array format from object store stream (simulates S3)
636        let json_data = r#"[{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}, {"id": 3, "name": "charlie"}]"#;
637
638        // Use InMemory store which returns Stream payload
639        let store = Arc::new(InMemory::new());
640        let path = Path::from("test_stream.json");
641        store
642            .put(&path, PutPayload::from_static(json_data.as_bytes()))
643            .await?;
644
645        let opener = JsonOpener::new(
646            2, // small batch size to test multiple batches
647            test_schema(),
648            FileCompressionType::UNCOMPRESSED,
649            store.clone(),
650            false, // JSON array format
651        );
652
653        let meta = store.head(&path).await?;
654        let file = PartitionedFile::new(path.to_string(), meta.size);
655
656        let stream = opener.open(file)?.await?;
657        let batches: Vec<_> = stream.try_collect().await?;
658
659        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
660        assert_eq!(total_rows, 3);
661
662        Ok(())
663    }
664
665    #[tokio::test]
666    async fn test_json_array_nested_objects() -> Result<()> {
667        // Test JSON array with nested objects and arrays
668        let schema = Arc::new(Schema::new(vec![
669            Field::new("id", DataType::Int64, true),
670            Field::new("data", DataType::Utf8, true),
671        ]));
672
673        let json_data = r#"[
674            {"id": 1, "data": "{\"nested\": true}"},
675            {"id": 2, "data": "[1, 2, 3]"}
676        ]"#;
677
678        let store = Arc::new(InMemory::new());
679        let path = Path::from("nested.json");
680        store
681            .put(&path, PutPayload::from_static(json_data.as_bytes()))
682            .await?;
683
684        let opener = JsonOpener::new(
685            1024,
686            schema,
687            FileCompressionType::UNCOMPRESSED,
688            store.clone(),
689            false,
690        );
691
692        let meta = store.head(&path).await?;
693        let file = PartitionedFile::new(path.to_string(), meta.size);
694
695        let stream = opener.open(file)?.await?;
696        let batches: Vec<_> = stream.try_collect().await?;
697
698        assert_eq!(batches[0].num_rows(), 2);
699
700        Ok(())
701    }
702
703    #[tokio::test]
704    async fn test_json_array_empty() -> Result<()> {
705        // Test empty JSON array
706        let json_data = "[]";
707
708        let store = Arc::new(InMemory::new());
709        let path = Path::from("empty.json");
710        store
711            .put(&path, PutPayload::from_static(json_data.as_bytes()))
712            .await?;
713
714        let opener = JsonOpener::new(
715            1024,
716            test_schema(),
717            FileCompressionType::UNCOMPRESSED,
718            store.clone(),
719            false,
720        );
721
722        let meta = store.head(&path).await?;
723        let file = PartitionedFile::new(path.to_string(), meta.size);
724
725        let stream = opener.open(file)?.await?;
726        let batches: Vec<_> = stream.try_collect().await?;
727
728        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
729        assert_eq!(total_rows, 0);
730
731        Ok(())
732    }
733
734    #[tokio::test]
735    async fn test_json_array_range_not_supported() {
736        // Test that range-based scanning returns error for JSON array format
737        let store = Arc::new(InMemory::new());
738        let path = Path::from("test.json");
739        store
740            .put(&path, PutPayload::from_static(b"[]"))
741            .await
742            .unwrap();
743
744        let opener = JsonOpener::new(
745            1024,
746            test_schema(),
747            FileCompressionType::UNCOMPRESSED,
748            store.clone(),
749            false, // JSON array format
750        );
751
752        let meta = store.head(&path).await.unwrap();
753        let mut file = PartitionedFile::new(path.to_string(), meta.size);
754        file.range = Some(FileRange { start: 0, end: 10 });
755
756        let result = opener.open(file);
757        match result {
758            Ok(_) => panic!("Expected error for range-based JSON array scanning"),
759            Err(e) => {
760                assert!(
761                    e.to_string().contains("does not support range-based"),
762                    "Unexpected error message: {e}"
763                );
764            }
765        }
766    }
767
768    #[tokio::test]
769    async fn test_ndjson_still_works() -> Result<()> {
770        // Ensure NDJSON format still works correctly
771        let json_data =
772            "{\"id\": 1, \"name\": \"alice\"}\n{\"id\": 2, \"name\": \"bob\"}\n";
773
774        let store = Arc::new(InMemory::new());
775        let path = Path::from("test.ndjson");
776        store
777            .put(&path, PutPayload::from_static(json_data.as_bytes()))
778            .await?;
779
780        let opener = JsonOpener::new(
781            1024,
782            test_schema(),
783            FileCompressionType::UNCOMPRESSED,
784            store.clone(),
785            true, // NDJSON format
786        );
787
788        let meta = store.head(&path).await?;
789        let file = PartitionedFile::new(path.to_string(), meta.size);
790
791        let stream = opener.open(file)?.await?;
792        let batches: Vec<_> = stream.try_collect().await?;
793
794        assert_eq!(batches.len(), 1);
795        assert_eq!(batches[0].num_rows(), 2);
796
797        Ok(())
798    }
799
800    #[tokio::test]
801    async fn test_json_array_large_file() -> Result<()> {
802        // Test with a larger JSON array to verify streaming works
803        let mut json_data = String::from("[");
804        for i in 0..1000 {
805            if i > 0 {
806                json_data.push(',');
807            }
808            json_data.push_str(&format!(r#"{{"id": {i}, "name": "user{i}"}}"#));
809        }
810        json_data.push(']');
811
812        let store = Arc::new(InMemory::new());
813        let path = Path::from("large.json");
814        store
815            .put(&path, PutPayload::from(Bytes::from(json_data)))
816            .await?;
817
818        let opener = JsonOpener::new(
819            100, // batch size of 100
820            test_schema(),
821            FileCompressionType::UNCOMPRESSED,
822            store.clone(),
823            false,
824        );
825
826        let meta = store.head(&path).await?;
827        let file = PartitionedFile::new(path.to_string(), meta.size);
828
829        let stream = opener.open(file)?.await?;
830        let batches: Vec<_> = stream.try_collect().await?;
831
832        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
833        assert_eq!(total_rows, 1000);
834
835        // Should have multiple batches due to batch_size=100
836        assert!(batches.len() >= 10);
837
838        Ok(())
839    }
840
841    #[tokio::test]
842    async fn test_json_array_stream_cancellation() -> Result<()> {
843        // Test that cancellation works correctly (tasks are aborted when stream is dropped)
844        let mut json_data = String::from("[");
845        for i in 0..10000 {
846            if i > 0 {
847                json_data.push(',');
848            }
849            json_data.push_str(&format!(r#"{{"id": {i}, "name": "user{i}"}}"#));
850        }
851        json_data.push(']');
852
853        let store = Arc::new(InMemory::new());
854        let path = Path::from("cancel_test.json");
855        store
856            .put(&path, PutPayload::from(Bytes::from(json_data)))
857            .await?;
858
859        let opener = JsonOpener::new(
860            10, // small batch size
861            test_schema(),
862            FileCompressionType::UNCOMPRESSED,
863            store.clone(),
864            false,
865        );
866
867        let meta = store.head(&path).await?;
868        let file = PartitionedFile::new(path.to_string(), meta.size);
869
870        let mut stream = opener.open(file)?.await?;
871
872        // Read only first batch, then drop the stream (simulating cancellation)
873        let first_batch = stream.next().await;
874        assert!(first_batch.is_some());
875
876        // Drop the stream - this should abort the spawned tasks via SpawnedTask's Drop
877        drop(stream);
878
879        // Give tasks time to be aborted
880        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
881
882        // If we reach here without hanging, cancellation worked
883        Ok(())
884    }
885
886    fn get_partition_splits() -> Vec<usize> {
887        vec![1usize, 2, 3, 5, 7, 10]
888    }
889
890    /// Opens each byte-range partition of `path` in `store` and collects all
891    /// record batches produced across every partition.
892    async fn collect_partitioned_batches(
893        store: Arc<dyn ObjectStore>,
894        path: &Path,
895        file_size: u64,
896        num_partitions: usize,
897    ) -> Result<Vec<RecordBatch>> {
898        let mut all_batches = Vec::new();
899        for p in 0..num_partitions {
900            let start = (p as u64 * file_size) / num_partitions as u64;
901            let end = ((p as u64 + 1) * file_size) / num_partitions as u64;
902
903            let meta = store.head(path).await?;
904            let mut file = PartitionedFile::new(path.to_string(), meta.size);
905            file.range = Some(FileRange {
906                start: start as i64,
907                end: end as i64,
908            });
909
910            let opener = JsonOpener::new(
911                1024,
912                test_schema(),
913                FileCompressionType::UNCOMPRESSED,
914                Arc::clone(&store),
915                true,
916            );
917
918            let stream = opener.open(file)?.await?;
919            let batches: Vec<_> = stream.try_collect().await?;
920            all_batches.extend(batches);
921        }
922        Ok(all_batches)
923    }
924
925    /// Concatenates `batches` and returns a single batch sorted ascending by
926    /// the first (id) column.
927    fn concat_and_sort_by_id(batches: &[RecordBatch]) -> Result<RecordBatch> {
928        let schema = test_schema();
929        let combined = compute::concat_batches(&schema, batches)?;
930        let indices = compute::sort_to_indices(combined.column(0), None, None)?;
931        let sorted_cols: Vec<_> = combined
932            .columns()
933            .iter()
934            .map(|col| compute::take(col.as_ref(), &indices, None))
935            .collect::<std::result::Result<_, _>>()?;
936        Ok(RecordBatch::try_new(schema, sorted_cols)?)
937    }
938
939    #[tokio::test]
940    async fn test_ndjson_partitioned() -> Result<()> {
941        // Build an NDJSON file with a known number of rows.
942        let num_rows: usize = 20;
943        let mut ndjson = String::new();
944        for i in 0..num_rows {
945            ndjson.push_str(&format!("{{\"id\": {i}, \"name\": \"user{i}\"}}\n"));
946        }
947        let ndjson_bytes = Bytes::from(ndjson);
948        let file_size = ndjson_bytes.len() as u64;
949
950        for &cs in CHUNK_SIZES {
951            let (store, path) = make_chunked_store(&ndjson_bytes, cs).await;
952
953            for num_partitions in get_partition_splits() {
954                let batches = collect_partitioned_batches(
955                    Arc::clone(&store),
956                    &path,
957                    file_size,
958                    num_partitions,
959                )
960                .await?;
961
962                let total: usize = batches.iter().map(|b| b.num_rows()).sum();
963                assert_eq!(
964                    total, num_rows,
965                    "Expected {num_rows} rows with {num_partitions} partitions"
966                );
967
968                let result = concat_and_sort_by_id(&batches)?;
969                let ids = result
970                    .column(0)
971                    .as_any()
972                    .downcast_ref::<Int64Array>()
973                    .unwrap();
974                let names = result
975                    .column(1)
976                    .as_any()
977                    .downcast_ref::<StringArray>()
978                    .unwrap();
979                for i in 0..num_rows {
980                    assert_eq!(
981                        ids.value(i),
982                        i as i64,
983                        "id mismatch at row {i} with {num_partitions} partitions"
984                    );
985                    assert_eq!(
986                        names.value(i),
987                        format!("user{i}"),
988                        "name mismatch at row {i} with {num_partitions} partitions"
989                    );
990                }
991            }
992        }
993
994        Ok(())
995    }
996
997    #[tokio::test]
998    async fn test_ndjson_partitioned_uneven_lines() -> Result<()> {
999        // Lines of deliberately varying lengths so byte-range boundaries are
1000        // more likely to land in the middle of a line.
1001        let rows: &[(&str, &str)] = &[
1002            ("1", "alice"),
1003            ("2", "bob-with-a-longer-name"),
1004            ("3", "charlie"),
1005            ("4", "x"),
1006            ("5", "diana-has-an-even-longer-name-here"),
1007            ("6", "ed"),
1008            ("7", "francesca"),
1009            ("8", "g"),
1010            ("9", "hector-the-magnificent"),
1011            ("10", "isabella"),
1012        ];
1013        let num_rows = rows.len();
1014
1015        let mut ndjson = String::new();
1016        for (id, name) in rows {
1017            ndjson.push_str(&format!("{{\"id\": {id}, \"name\": \"{name}\"}}\n"));
1018        }
1019        let ndjson_bytes = Bytes::from(ndjson);
1020        let file_size = ndjson_bytes.len() as u64;
1021
1022        for &cs in CHUNK_SIZES {
1023            let (store, path) = make_chunked_store(&ndjson_bytes, cs).await;
1024
1025            for num_partitions in get_partition_splits() {
1026                let batches = collect_partitioned_batches(
1027                    Arc::clone(&store),
1028                    &path,
1029                    file_size,
1030                    num_partitions,
1031                )
1032                .await?;
1033
1034                let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1035                assert_eq!(
1036                    total, num_rows,
1037                    "Expected {num_rows} rows with {num_partitions} partitions"
1038                );
1039
1040                let result = concat_and_sort_by_id(&batches)?;
1041                let ids = result
1042                    .column(0)
1043                    .as_any()
1044                    .downcast_ref::<Int64Array>()
1045                    .unwrap();
1046                let names = result
1047                    .column(1)
1048                    .as_any()
1049                    .downcast_ref::<StringArray>()
1050                    .unwrap();
1051                for (i, (expected_id, expected_name)) in rows.iter().enumerate() {
1052                    assert_eq!(
1053                        ids.value(i),
1054                        expected_id.parse::<i64>().unwrap(),
1055                        "id mismatch at row {i} with {num_partitions} partitions"
1056                    );
1057                    assert_eq!(
1058                        names.value(i),
1059                        *expected_name,
1060                        "name mismatch at row {i} with {num_partitions} partitions"
1061                    );
1062                }
1063            }
1064        }
1065
1066        Ok(())
1067    }
1068
1069    #[tokio::test]
1070    async fn test_ndjson_partitioned_single_entry() -> Result<()> {
1071        // A single JSON object with no trailing newline. No matter how many
1072        // byte-range partitions the file is split into, exactly one row must
1073        // be produced in total.
1074        let ndjson = r#"{"id": 1, "name": "alice"}"#;
1075        let ndjson_bytes = Bytes::from(ndjson);
1076        let file_size = ndjson_bytes.len() as u64;
1077
1078        for &cs in CHUNK_SIZES {
1079            let (store, path) = make_chunked_store(&ndjson_bytes, cs).await;
1080
1081            for num_partitions in get_partition_splits() {
1082                let batches = collect_partitioned_batches(
1083                    Arc::clone(&store),
1084                    &path,
1085                    file_size,
1086                    num_partitions,
1087                )
1088                .await?;
1089
1090                let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1091                assert_eq!(
1092                    total, 1,
1093                    "Expected exactly 1 row with {num_partitions} partitions"
1094                );
1095
1096                let result = concat_and_sort_by_id(&batches)?;
1097                let ids = result
1098                    .column(0)
1099                    .as_any()
1100                    .downcast_ref::<Int64Array>()
1101                    .unwrap();
1102                let names = result
1103                    .column(1)
1104                    .as_any()
1105                    .downcast_ref::<StringArray>()
1106                    .unwrap();
1107                assert_eq!(ids.value(0), 1);
1108                assert_eq!(names.value(0), "alice");
1109            }
1110        }
1111
1112        Ok(())
1113    }
1114}