Skip to main content

nautilus_persistence/backend/parquet/
io.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16#![expect(
17    clippy::missing_errors_doc,
18    clippy::missing_panics_doc,
19    reason = "Parquet I/O functions forward Arrow/object-store errors and use validated schema paths"
20)]
21
22use std::{collections::HashMap, sync::Arc};
23
24use ahash::AHashMap;
25use anyhow::Context;
26use arrow::{
27    array::{
28        Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeListArray, ListArray,
29        StringBuilder, StructArray, UInt32Array, UInt64Array,
30    },
31    buffer::{OffsetBuffer, ScalarBuffer},
32    compute::cast,
33    datatypes::{DataType, Field, Fields, Schema},
34    record_batch::RecordBatch,
35};
36use nautilus_model::{data::NautilusRecordType, instruments::InstrumentAny};
37use nautilus_serialization::arrow::{
38    ArrowSchemaProvider, EncodeToRecordBatch, KEY_IDENTIFIER, KEY_PRICE_PRECISION,
39    KEY_SIZE_PRECISION, instrument::decode_instrument_any_batch, is_nautilus_legacy_schema,
40    is_nautilus_timestamp_schema, normalize_legacy_fixed_columns,
41    normalized_legacy_data_type as normalize_legacy_arrow_data_type, normalized_timestamp_type,
42};
43use object_store::{
44    ObjectStore, ObjectStoreExt, PutMode, PutOptions, buffered::BufReader, path::Path as ObjectPath,
45};
46use parquet::{
47    arrow::{
48        ArrowSchemaConverter, ArrowWriter, ParquetRecordBatchStreamBuilder,
49        arrow_reader::ParquetRecordBatchReaderBuilder,
50    },
51    basic::{Compression, ZstdLevel},
52    file::{
53        metadata::{KeyValue, SortingColumn},
54        properties::WriterProperties,
55        reader::{FileReader, SerializedFileReader},
56        statistics::Statistics,
57    },
58    schema::types::ColumnPath,
59};
60use url::Url;
61
62use crate::common::arrow::catalog_record_schema;
63
64const DEPTH10_LEN: usize = 10;
65
66pub(crate) fn is_remote_uri_scheme(scheme: &str) -> bool {
67    matches!(
68        scheme,
69        "s3" | "gs" | "gcs" | "az" | "abfs" | "http" | "https"
70    )
71}
72
73pub(crate) fn remote_store_root_url(uri: &str) -> anyhow::Result<Url> {
74    let mut url = Url::parse(uri)?;
75    url.set_path("");
76    url.set_query(None);
77    url.set_fragment(None);
78    Ok(url)
79}
80
81pub(crate) fn remote_full_uri(uri: &str, object_path: &str) -> anyhow::Result<String> {
82    let root = remote_store_root_url(uri)?;
83    let root = root.as_str().trim_end_matches('/');
84    let object_path = object_path.trim_start_matches('/');
85
86    if object_path.is_empty() {
87        Ok(root.to_string())
88    } else {
89        Ok(format!("{root}/{object_path}"))
90    }
91}
92
93/// Normalizes supported legacy Parquet physical encodings for explicit migration.
94///
95/// Older Python/v1 catalog writers can emit low-cardinality strings as Arrow dictionary
96/// arrays and instrument `info` values as the JSON bytes `null` rather than Arrow nulls.
97pub(crate) fn normalize_legacy_parquet_columns(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
98    if let Some(schema) = normalize_legacy_record_schema(batch.schema_ref()) {
99        let batch = normalize_legacy_info_column(batch)?;
100        let batch = normalize_legacy_fixed_columns(&batch)?;
101        return Ok(nautilus_serialization::arrow::record_batch_with_timestamps(
102            Arc::new(schema),
103            batch.columns().to_vec(),
104        )?);
105    }
106
107    if is_legacy_instrument_schema(batch.schema_ref()) {
108        let metadata = batch.schema().metadata().clone();
109        if batch.num_rows() == 0 {
110            return Ok(RecordBatch::new_empty(Arc::new(InstrumentAny::get_schema(
111                Some(metadata),
112            ))));
113        }
114        let batch = normalize_legacy_info_column(batch)?;
115        let instruments = decode_instrument_any_batch(&metadata, &batch)?;
116        return Ok(InstrumentAny::encode_batch(&metadata, &instruments)?);
117    }
118
119    let normalize_legacy = is_nautilus_legacy_schema(batch.schema_ref());
120    let batch = if normalize_legacy {
121        normalize_dictionary_string_columns(batch)?
122    } else {
123        batch.clone()
124    };
125    let batch = normalize_legacy_fixed_columns(&batch)?;
126    let batch = normalize_legacy_info_column(&batch)?;
127    normalize_legacy_depth_columns(&batch)
128}
129
130/// Normalizes the Arrow schema changes made by [`normalize_legacy_parquet_columns`].
131#[must_use]
132pub(crate) fn normalize_legacy_parquet_schema(schema: &Schema) -> Schema {
133    if let Some(schema) = normalize_legacy_record_schema(schema) {
134        return schema;
135    }
136
137    if is_legacy_instrument_schema(schema) {
138        return InstrumentAny::get_schema(Some(schema.metadata().clone()));
139    }
140
141    let normalize_fixed = is_nautilus_legacy_schema(schema);
142    let normalize_timestamps = is_nautilus_timestamp_schema(schema);
143    let fields = schema
144        .fields()
145        .iter()
146        .map(|field| {
147            let data_type =
148                normalized_legacy_data_type(field.name(), field.data_type(), normalize_fixed);
149            let data_type = if schema.metadata().contains_key("type_name")
150                && matches!(field.name().as_str(), "ts_event" | "ts_init")
151                && data_type == DataType::UInt64
152            {
153                nautilus_serialization::arrow::timestamp_data_type()
154            } else if normalize_timestamps {
155                normalized_timestamp_type(&data_type)
156            } else {
157                data_type
158            };
159            let nullable = field.is_nullable()
160                || (normalize_fixed
161                    && matches!(field.data_type(), DataType::FixedSizeBinary(8 | 16)))
162                || (field.name() == "info" && field.data_type() == &DataType::Binary);
163            Arc::new(
164                field
165                    .as_ref()
166                    .clone()
167                    .with_data_type(data_type)
168                    .with_nullable(nullable),
169            )
170        })
171        .collect::<Vec<_>>();
172    normalize_legacy_depth_schema(Schema::new_with_metadata(fields, schema.metadata().clone()))
173}
174
175fn normalize_legacy_record_schema(schema: &Schema) -> Option<Schema> {
176    let record_type = schema
177        .metadata()
178        .get("type")?
179        .parse::<NautilusRecordType>()
180        .ok()?;
181    let current = catalog_record_schema(record_type).ok()?;
182    let fields = schema
183        .fields()
184        .iter()
185        .map(|field| {
186            let Ok(expected) = current.field_with_name(field.name()) else {
187                return field.clone();
188            };
189
190            if field.data_type() == expected.data_type()
191                || (expected.data_type() == &nautilus_serialization::arrow::timestamp_data_type()
192                    && (field.data_type() == &DataType::UInt64
193                        || normalized_timestamp_type(field.data_type()) == *expected.data_type()))
194                || (field.name() == "info" && field.data_type() == &DataType::Binary)
195            {
196                Arc::new(expected.clone())
197            } else {
198                field.clone()
199            }
200        })
201        .collect::<Vec<_>>();
202    Some(Schema::new_with_metadata(fields, schema.metadata().clone()))
203}
204
205fn is_legacy_instrument_schema(schema: &Schema) -> bool {
206    schema.metadata().contains_key("class")
207        && schema
208            .field_with_name("ts_init")
209            .is_ok_and(|field| field.data_type() == &DataType::UInt64)
210}
211
212/// Casts dictionary-encoded string columns from legacy Parquet files to plain UTF-8 columns.
213///
214/// Older Python/v1 catalog writers can emit low-cardinality strings as Arrow dictionary
215/// arrays. Rust decoders generally expect concrete `Utf8` columns, so Parquet reads normalize
216/// this physical encoding before typed decoding.
217fn normalize_dictionary_string_columns(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
218    let schema = batch.schema();
219    let mut changed = false;
220    let mut fields = Vec::with_capacity(schema.fields().len());
221    let mut columns: Vec<ArrayRef> = Vec::with_capacity(batch.num_columns());
222
223    for (field, column) in schema.fields().iter().zip(batch.columns()) {
224        let data_type = normalized_dictionary_data_type(field.data_type());
225
226        changed |= &data_type != field.data_type();
227        fields.push(Arc::new(
228            field.as_ref().clone().with_data_type(data_type.clone()),
229        ));
230
231        if column.data_type() == &data_type {
232            columns.push(column.clone());
233        } else {
234            columns.push(cast(column.as_ref(), &data_type)?);
235        }
236    }
237
238    if !changed {
239        return Ok(batch.clone());
240    }
241
242    let schema = Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone()));
243    Ok(RecordBatch::try_new(schema, columns)?)
244}
245
246fn normalized_legacy_data_type(
247    name: &str,
248    data_type: &DataType,
249    normalize_fixed: bool,
250) -> DataType {
251    if normalize_fixed
252        && let DataType::FixedSizeList(item, length) = data_type
253        && matches!(item.data_type(), DataType::FixedSizeBinary(8 | 16))
254    {
255        return DataType::FixedSizeList(
256            Arc::new(
257                item.as_ref()
258                    .clone()
259                    .with_data_type(normalize_legacy_arrow_data_type(name, item.data_type()))
260                    .with_nullable(true),
261            ),
262            *length,
263        );
264    }
265
266    if normalize_fixed {
267        let normalized = normalize_legacy_arrow_data_type(name, data_type);
268        if &normalized != data_type {
269            return normalized;
270        }
271    }
272
273    match data_type {
274        DataType::Dictionary(_, value_type)
275            if normalize_fixed && matches!(value_type.as_ref(), DataType::Utf8) =>
276        {
277            DataType::Utf8
278        }
279        DataType::Binary if name == "info" => DataType::Utf8,
280        _ => data_type.clone(),
281    }
282}
283
284fn normalized_dictionary_data_type(data_type: &DataType) -> DataType {
285    match data_type {
286        DataType::Dictionary(_, value_type) if matches!(value_type.as_ref(), DataType::Utf8) => {
287            DataType::Utf8
288        }
289        _ => data_type.clone(),
290    }
291}
292
293fn normalize_legacy_info_column(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
294    let Some(info_index) = batch.schema().index_of("info").ok() else {
295        return Ok(batch.clone());
296    };
297
298    let column = batch.column(info_index);
299    if column.data_type() != &DataType::Binary {
300        return Ok(batch.clone());
301    }
302
303    let info = column
304        .as_any()
305        .downcast_ref::<BinaryArray>()
306        .expect("Binary column should downcast to BinaryArray");
307
308    let mut builder = StringBuilder::new();
309
310    for row in 0..info.len() {
311        if info.is_null(row) || info.value(row) == b"null" {
312            builder.append_null();
313        } else {
314            builder.append_value(std::str::from_utf8(info.value(row))?);
315        }
316    }
317
318    let mut fields = batch.schema().fields().iter().cloned().collect::<Vec<_>>();
319    fields[info_index] = Arc::new(
320        fields[info_index]
321            .as_ref()
322            .clone()
323            .with_data_type(DataType::Utf8)
324            .with_nullable(true),
325    );
326    let mut columns = batch.columns().to_vec();
327    columns[info_index] = Arc::new(builder.finish());
328    let schema = Arc::new(Schema::new_with_metadata(
329        fields,
330        batch.schema().metadata().clone(),
331    ));
332    Ok(RecordBatch::try_new(schema, columns)?)
333}
334
335fn normalize_legacy_depth_schema(schema: Schema) -> Schema {
336    if !has_legacy_depth_columns(&schema) {
337        return schema;
338    }
339
340    let mut fields = vec![depth_side_field("bids"), depth_side_field("asks")];
341    fields.extend(
342        schema
343            .fields()
344            .iter()
345            .filter(|field| !is_legacy_depth_column(field.name()))
346            .cloned(),
347    );
348    Schema::new_with_metadata(fields, schema.metadata().clone())
349}
350
351fn normalize_legacy_depth_columns(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
352    if !has_legacy_depth_columns(batch.schema().as_ref()) {
353        return Ok(batch.clone());
354    }
355
356    let flat = batch.schema().index_of("bid_price_0").is_ok();
357    let side_values = |side: &str, value: &str| {
358        let name = format!("{side}_{value}");
359        if flat {
360            match value {
361                "price" | "size" => decimal_depth_list(batch, &name),
362                "count" => u32_depth_list(batch, &name),
363                "order_id" => u64_depth_list(batch, &name),
364                _ => unreachable!("depth field inventory is fixed"),
365            }
366            .and_then(|list| depth_list_values(&list))
367        } else {
368            if batch.column_by_name(&name).is_none() && matches!(value, "count" | "order_id") {
369                let width = legacy_fixed_list_width(batch, side)?;
370                let len = batch.num_rows().checked_mul(width).ok_or_else(|| {
371                    anyhow::anyhow!("Legacy depth column '{name}' length overflow")
372                })?;
373                return match value {
374                    "count" => Ok(Arc::new(UInt32Array::from(vec![0; len])) as ArrayRef),
375                    "order_id" => Ok(Arc::new(UInt64Array::from(vec![0; len])) as ArrayRef),
376                    _ => unreachable!("missing legacy depth defaults are fixed"),
377                };
378            }
379            depth_list_values(
380                batch
381                    .column_by_name(&name)
382                    .ok_or_else(|| anyhow::anyhow!("Missing legacy depth column '{name}'"))?,
383            )
384        }
385    };
386    let mut columns = vec![
387        depth_side_array(
388            &side_values("bid", "price")?,
389            &side_values("bid", "size")?,
390            &side_values("bid", "count")?,
391            &side_values("bid", "order_id")?,
392            batch.num_rows(),
393        )?,
394        depth_side_array(
395            &side_values("ask", "price")?,
396            &side_values("ask", "size")?,
397            &side_values("ask", "count")?,
398            &side_values("ask", "order_id")?,
399            batch.num_rows(),
400        )?,
401    ];
402    columns.extend(
403        batch
404            .schema()
405            .fields()
406            .iter()
407            .zip(batch.columns())
408            .filter(|(field, _)| !is_legacy_depth_column(field.name()))
409            .map(|(_, column)| column.clone()),
410    );
411    let schema = Arc::new(normalize_legacy_depth_schema(
412        batch.schema().as_ref().clone(),
413    ));
414    Ok(RecordBatch::try_new(schema, columns)?)
415}
416
417fn legacy_fixed_list_width(batch: &RecordBatch, side: &str) -> anyhow::Result<usize> {
418    let schema = batch.schema();
419    ["price", "size", "count", "order_id"]
420        .iter()
421        .filter_map(|value| schema.field_with_name(&format!("{side}_{value}")).ok())
422        .find_map(|field| match field.data_type() {
423            DataType::FixedSizeList(_, width) => usize::try_from(*width).ok(),
424            _ => None,
425        })
426        .ok_or_else(|| anyhow::anyhow!("Missing legacy depth FixedSizeList width for '{side}'"))
427}
428
429fn has_legacy_depth_columns(schema: &Schema) -> bool {
430    if schema.index_of("bids").is_ok() {
431        return false;
432    }
433
434    let has_fixed_lists = ["bid_price", "ask_price", "bid_size", "ask_size"]
435        .iter()
436        .all(|name| {
437            schema
438                .field_with_name(name)
439                .is_ok_and(|field| matches!(field.data_type(), DataType::FixedSizeList(_, _)))
440        });
441    let has_flat_levels = ["bid_price_0", "ask_price_0", "bid_size_0", "ask_size_0"]
442        .iter()
443        .all(|name| schema.index_of(name).is_ok());
444
445    has_fixed_lists || has_flat_levels
446}
447
448fn depth_level_fields() -> Fields {
449    vec![
450        Field::new("price", DataType::Decimal128(38, 16), false),
451        Field::new("size", DataType::Decimal128(38, 16), false),
452        Field::new("count", DataType::UInt32, false),
453        Field::new("order_id", DataType::UInt64, false),
454    ]
455    .into()
456}
457
458fn depth_side_field(name: &str) -> Arc<Field> {
459    let fields = depth_level_fields();
460    Arc::new(Field::new(
461        name,
462        DataType::List(Arc::new(Field::new(
463            "item",
464            DataType::Struct(fields),
465            false,
466        ))),
467        false,
468    ))
469}
470
471fn decimal_depth_list(batch: &RecordBatch, prefix: &str) -> anyhow::Result<ArrayRef> {
472    let mut values = Vec::with_capacity(batch.num_rows() * DEPTH10_LEN);
473    let arrays = (0..DEPTH10_LEN)
474        .map(|level| {
475            let name = format!("{prefix}_{level}");
476            batch
477                .column_by_name(&name)
478                .and_then(|column| column.as_any().downcast_ref::<Decimal128Array>())
479                .ok_or_else(|| anyhow::anyhow!("Legacy depth column '{name}' must be Decimal128"))
480        })
481        .collect::<Result<Vec<_>, _>>()?;
482
483    for row in 0..batch.num_rows() {
484        for array in &arrays {
485            values.push((!array.is_null(row)).then(|| array.value(row)));
486        }
487    }
488    let values = Decimal128Array::from(values).with_precision_and_scale(38, 16)?;
489    Ok(depth_list_array(Arc::new(values), true))
490}
491
492fn u64_depth_list(batch: &RecordBatch, prefix: &str) -> anyhow::Result<ArrayRef> {
493    let arrays = (0..DEPTH10_LEN)
494        .map(|level| {
495            let name = format!("{prefix}_{level}");
496            batch
497                .column_by_name(&name)
498                .map(|column| {
499                    column
500                        .as_any()
501                        .downcast_ref::<UInt64Array>()
502                        .ok_or_else(|| {
503                            anyhow::anyhow!("Legacy depth column '{name}' must be UInt64")
504                        })
505                })
506                .transpose()
507        })
508        .collect::<Result<Vec<_>, _>>()?;
509    let mut values = Vec::with_capacity(batch.num_rows() * DEPTH10_LEN);
510    for row in 0..batch.num_rows() {
511        for array in &arrays {
512            values.push(array.map_or(0, |array| array.value(row)));
513        }
514    }
515    Ok(depth_list_array(Arc::new(UInt64Array::from(values)), false))
516}
517
518fn u32_depth_list(batch: &RecordBatch, prefix: &str) -> anyhow::Result<ArrayRef> {
519    let arrays = (0..DEPTH10_LEN)
520        .map(|level| {
521            let name = format!("{prefix}_{level}");
522            batch
523                .column_by_name(&name)
524                .and_then(|column| column.as_any().downcast_ref::<UInt32Array>())
525                .ok_or_else(|| anyhow::anyhow!("Legacy depth column '{name}' must be UInt32"))
526        })
527        .collect::<Result<Vec<_>, _>>()?;
528    let mut values = Vec::with_capacity(batch.num_rows() * DEPTH10_LEN);
529    for row in 0..batch.num_rows() {
530        for array in &arrays {
531            values.push(array.value(row));
532        }
533    }
534    Ok(depth_list_array(Arc::new(UInt32Array::from(values)), false))
535}
536
537fn depth_list_array(values: ArrayRef, values_nullable: bool) -> ArrayRef {
538    Arc::new(FixedSizeListArray::new(
539        Arc::new(Field::new(
540            "item",
541            values.data_type().clone(),
542            values_nullable,
543        )),
544        i32::try_from(DEPTH10_LEN).expect("depth-10 length fits i32"),
545        values,
546        None,
547    ))
548}
549
550fn depth_list_values(list: &ArrayRef) -> anyhow::Result<ArrayRef> {
551    list.as_any()
552        .downcast_ref::<FixedSizeListArray>()
553        .map(|list| list.values().clone())
554        .ok_or_else(|| anyhow::anyhow!("Legacy depth column must be FixedSizeList"))
555}
556
557fn depth_side_array(
558    prices: &ArrayRef,
559    sizes: &ArrayRef,
560    counts: &ArrayRef,
561    order_ids: &ArrayRef,
562    rows: usize,
563) -> anyhow::Result<ArrayRef> {
564    let prices = prices
565        .as_any()
566        .downcast_ref::<Decimal128Array>()
567        .ok_or_else(|| anyhow::anyhow!("Legacy depth prices must be Decimal128"))?;
568    let sizes = sizes
569        .as_any()
570        .downcast_ref::<Decimal128Array>()
571        .ok_or_else(|| anyhow::anyhow!("Legacy depth sizes must be Decimal128"))?;
572    let counts = counts
573        .as_any()
574        .downcast_ref::<UInt32Array>()
575        .ok_or_else(|| anyhow::anyhow!("Legacy depth counts must be UInt32"))?;
576    let order_ids = order_ids
577        .as_any()
578        .downcast_ref::<UInt64Array>()
579        .ok_or_else(|| anyhow::anyhow!("Legacy depth order IDs must be UInt64"))?;
580    let expected_len = prices.len();
581    anyhow::ensure!(
582        sizes.len() == expected_len
583            && counts.len() == expected_len
584            && order_ids.len() == expected_len,
585        "Legacy depth columns must contain the same number of values"
586    );
587    let width = if rows == 0 {
588        0
589    } else {
590        anyhow::ensure!(
591            expected_len.is_multiple_of(rows),
592            "Legacy depth column length {expected_len} is not divisible by row count {rows}"
593        );
594        expected_len / rows
595    };
596
597    let mut open_prices = Vec::with_capacity(expected_len);
598    let mut open_sizes = Vec::with_capacity(expected_len);
599    let mut open_counts = Vec::with_capacity(expected_len);
600    let mut open_order_ids = Vec::with_capacity(expected_len);
601    let mut offsets = Vec::with_capacity(rows + 1);
602    offsets.push(0);
603
604    for row in 0..rows {
605        for level in 0..width {
606            let index = row * width + level;
607            if prices.is_null(index) || sizes.is_null(index) {
608                continue;
609            }
610            open_prices.push(prices.value(index));
611            open_sizes.push(sizes.value(index));
612            open_counts.push(counts.value(index));
613            open_order_ids.push(order_ids.value(index));
614        }
615        offsets.push(i32::try_from(open_prices.len())?);
616    }
617
618    let fields = depth_level_fields();
619    let values = StructArray::try_new(
620        fields.clone(),
621        vec![
622            Arc::new(Decimal128Array::from(open_prices).with_precision_and_scale(38, 16)?),
623            Arc::new(Decimal128Array::from(open_sizes).with_precision_and_scale(38, 16)?),
624            Arc::new(UInt32Array::from(open_counts)),
625            Arc::new(UInt64Array::from(open_order_ids)),
626        ],
627        None,
628    )?;
629    Ok(Arc::new(ListArray::try_new(
630        Arc::new(Field::new("item", DataType::Struct(fields), false)),
631        OffsetBuffer::new(ScalarBuffer::from(offsets)),
632        Arc::new(values),
633        None,
634    )?))
635}
636
637fn is_legacy_depth_column(name: &str) -> bool {
638    const LIST_COLUMNS: &[&str] = &[
639        "bid_price",
640        "ask_price",
641        "bid_size",
642        "ask_size",
643        "bid_order_id",
644        "ask_order_id",
645        "bid_count",
646        "ask_count",
647    ];
648    LIST_COLUMNS.contains(&name)
649        || [
650            "bid_price_",
651            "ask_price_",
652            "bid_size_",
653            "ask_size_",
654            "bid_order_id_",
655            "ask_order_id_",
656            "bid_count_",
657            "ask_count_",
658        ]
659        .iter()
660        .any(|prefix| {
661            name.strip_prefix(prefix).is_some_and(|level| {
662                level
663                    .parse::<usize>()
664                    .is_ok_and(|level| level < DEPTH10_LEN)
665            })
666        })
667}
668
669pub(crate) struct ObjectStoreLocation {
670    pub object_store: Arc<dyn ObjectStore>,
671    pub base_path: String,
672    pub original_uri: String,
673    store_root_url: Option<Url>,
674}
675
676impl ObjectStoreLocation {
677    pub(crate) fn store_root_url(&self) -> Option<&Url> {
678        self.store_root_url.as_ref()
679    }
680}
681
682/// Writes a `RecordBatch` to a Parquet file using object store, with optional compression.
683///
684/// # Errors
685///
686/// Returns an error if writing to Parquet fails or any I/O operation fails.
687pub async fn write_batch_to_parquet(
688    batch: RecordBatch,
689    path: &str,
690    storage_options: Option<AHashMap<String, String>>,
691    compression: Option<parquet::basic::Compression>,
692    max_row_group_size: Option<usize>,
693) -> anyhow::Result<()> {
694    write_batches_to_parquet(
695        &[batch],
696        path,
697        storage_options,
698        compression,
699        max_row_group_size,
700    )
701    .await
702}
703
704/// Writes multiple `RecordBatch` items to a Parquet file using object store, with optional compression, row group sizing, and storage options.
705///
706/// # Errors
707///
708/// Returns an error if writing to Parquet fails or any I/O operation fails.
709pub async fn write_batches_to_parquet(
710    batches: &[RecordBatch],
711    path: &str,
712    storage_options: Option<AHashMap<String, String>>,
713    compression: Option<parquet::basic::Compression>,
714    max_row_group_size: Option<usize>,
715) -> anyhow::Result<()> {
716    let (object_store, base_path, _) = create_object_store_from_path(path, storage_options)?;
717    let object_path = if base_path.is_empty() {
718        ObjectPath::from(path)
719    } else {
720        ObjectPath::from(format!("{base_path}/{path}"))
721    };
722
723    write_batches_to_object_store(
724        batches,
725        object_store,
726        &object_path,
727        compression,
728        max_row_group_size,
729        None,
730    )
731    .await
732}
733
734/// Reads only the Arrow schema (including key/value metadata) of a Parquet object.
735///
736/// Avoids decoding any record batches; use when only schema metadata is needed.
737///
738/// # Errors
739///
740/// Returns an error if the object cannot be fetched or its footer cannot be parsed.
741pub async fn read_parquet_schema_from_object_store(
742    object_store: Arc<dyn ObjectStore>,
743    path: &ObjectPath,
744) -> anyhow::Result<Arc<arrow::datatypes::Schema>> {
745    let object = object_store.head(path).await?;
746    if object.size == 0 {
747        return Ok(Arc::new(arrow::datatypes::Schema::new(Vec::<
748            arrow::datatypes::Field,
749        >::new())));
750    }
751    let reader = BufReader::new(object_store, &object);
752    let builder = ParquetRecordBatchStreamBuilder::new(reader).await?;
753    Ok(builder.schema().clone())
754}
755
756/// Reads a Parquet file from an object store and returns all record batches plus
757/// the Arrow schema from the builder. The builder's schema includes metadata restored
758/// from the file's `ARROW:schema` `key_value_metadata`; use it for decoding instead of
759/// each batch's schema (which has metadata stripped).
760///
761/// # Errors
762///
763/// Returns an error if the path cannot be read or Parquet parsing fails.
764pub async fn read_parquet_from_object_store(
765    object_store: Arc<dyn ObjectStore>,
766    path: &ObjectPath,
767) -> anyhow::Result<(Vec<RecordBatch>, Arc<arrow::datatypes::Schema>)> {
768    let result: object_store::GetResult = object_store.get(path).await?;
769    let data = result.bytes().await?;
770    if data.is_empty() {
771        return Ok((
772            Vec::new(),
773            Arc::new(arrow::datatypes::Schema::new(
774                Vec::<arrow::datatypes::Field>::new(),
775            )),
776        ));
777    }
778    let builder = ParquetRecordBatchReaderBuilder::try_new(data)?;
779    let schema = builder.schema().clone();
780    let reader = builder.build()?;
781    let mut batches = Vec::new();
782    for batch in reader {
783        batches.push(batch?);
784    }
785    Ok((batches, schema))
786}
787
788/// Writes multiple `RecordBatch` items to an object store URI, with optional compression,
789/// row group sizing, and `key_value_metadata` (e.g. for instrument "class" so it survives roundtrip).
790///
791/// # Errors
792///
793/// Returns an error if writing to Parquet fails or any I/O operation fails.
794pub async fn write_batches_to_object_store(
795    batches: &[RecordBatch],
796    object_store: Arc<dyn ObjectStore>,
797    path: &ObjectPath,
798    compression: Option<parquet::basic::Compression>,
799    max_row_group_size: Option<usize>,
800    key_value_metadata: Option<Vec<KeyValue>>,
801) -> anyhow::Result<()> {
802    write_batches_to_object_store_with_mode(
803        batches,
804        object_store,
805        path,
806        compression,
807        max_row_group_size,
808        key_value_metadata,
809        PutMode::Overwrite,
810    )
811    .await
812}
813
814pub(crate) async fn write_batches_to_object_store_create(
815    batches: &[RecordBatch],
816    object_store: Arc<dyn ObjectStore>,
817    path: &ObjectPath,
818    compression: Option<parquet::basic::Compression>,
819    max_row_group_size: Option<usize>,
820    key_value_metadata: Option<Vec<KeyValue>>,
821) -> anyhow::Result<()> {
822    write_batches_to_object_store_with_mode(
823        batches,
824        object_store,
825        path,
826        compression,
827        max_row_group_size,
828        key_value_metadata,
829        PutMode::Create,
830    )
831    .await
832}
833
834async fn write_batches_to_object_store_with_mode(
835    batches: &[RecordBatch],
836    object_store: Arc<dyn ObjectStore>,
837    path: &ObjectPath,
838    compression: Option<parquet::basic::Compression>,
839    max_row_group_size: Option<usize>,
840    key_value_metadata: Option<Vec<KeyValue>>,
841    put_mode: PutMode,
842) -> anyhow::Result<()> {
843    // Create a temporary buffer to write the parquet data
844    let mut buffer = Vec::new();
845
846    let schema = batches[0].schema();
847    let sorting_columns = parquet_sorting_columns(schema.as_ref())?;
848    let mut props_builder = WriterProperties::builder()
849        .set_compression(compression.unwrap_or(Compression::ZSTD(ZstdLevel::default())))
850        .set_max_row_group_row_count(Some(
851            max_row_group_size.unwrap_or(super::DEFAULT_ROW_GROUP_SIZE),
852        ))
853        .set_sorting_columns(sorting_columns);
854
855    if schema.index_of(KEY_IDENTIFIER).is_ok() {
856        props_builder =
857            props_builder.set_column_bloom_filter_enabled(ColumnPath::from(KEY_IDENTIFIER), true);
858    }
859
860    if let Some(kv) = key_value_metadata {
861        props_builder = props_builder.set_key_value_metadata(Some(kv));
862    }
863    let writer_props = props_builder.build();
864
865    let mut writer = ArrowWriter::try_new(&mut buffer, schema, Some(writer_props))?;
866    for batch in batches {
867        writer.write(batch)?;
868    }
869    writer.close()?;
870
871    // Upload the buffer to object store
872    object_store
873        .put_opts(
874            path,
875            buffer.into(),
876            PutOptions {
877                mode: put_mode,
878                ..Default::default()
879            },
880        )
881        .await?;
882
883    Ok(())
884}
885
886fn parquet_sorting_columns(schema: &Schema) -> anyhow::Result<Option<Vec<SortingColumn>>> {
887    if schema.index_of("ts_init").is_err() {
888        return Ok(None);
889    }
890    let parquet_schema = ArrowSchemaConverter::new().convert(schema)?;
891    let mut names = Vec::with_capacity(2);
892    names.push("ts_init");
893    if schema.index_of(KEY_IDENTIFIER).is_ok() {
894        names.push(KEY_IDENTIFIER);
895    }
896    let columns = names
897        .into_iter()
898        .map(|name| {
899            let index = parquet_schema
900                .columns()
901                .iter()
902                .position(|column| {
903                    column
904                        .path()
905                        .parts()
906                        .first()
907                        .is_some_and(|part| part == name)
908                })
909                .ok_or_else(|| anyhow::anyhow!("Parquet schema is missing sort column {name}"))?;
910            Ok(SortingColumn {
911                column_idx: i32::try_from(index)?,
912                descending: false,
913                nulls_first: false,
914            })
915        })
916        .collect::<anyhow::Result<Vec<_>>>()?;
917    Ok(Some(columns))
918}
919
920/// Deduplicates a slice of `RecordBatch` items, removing rows that are identical across all columns.
921///
922/// Rows are compared by encoding each row to a canonical byte sequence using Arrow's row format.
923/// Only the first occurrence of each unique row is retained; the relative order of unique rows
924/// is preserved.
925///
926/// # Errors
927///
928/// Returns an error if the row converter cannot be constructed or if the `take` kernel fails.
929fn deduplicate_record_batches(batches: &[RecordBatch]) -> anyhow::Result<Vec<RecordBatch>> {
930    if batches.is_empty() {
931        return Ok(Vec::new());
932    }
933
934    let schema = batches[0].schema();
935
936    let fields: Vec<arrow::row::SortField> = schema
937        .fields()
938        .iter()
939        .map(|f| arrow::row::SortField::new(f.data_type().clone()))
940        .collect();
941
942    let converter = arrow::row::RowConverter::new(fields)?;
943    let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
944    let mut result: Vec<RecordBatch> = Vec::new();
945
946    for batch in batches {
947        let rows = converter.convert_columns(batch.columns())?;
948        let mut indices: Vec<u32> = Vec::new();
949
950        for (i, row) in rows.iter().enumerate() {
951            if seen.insert(row.as_ref().to_vec()) {
952                indices.push(u32::try_from(i)?);
953            }
954        }
955
956        if !indices.is_empty() {
957            let index_array = arrow::array::UInt32Array::from(indices);
958            let deduped_columns: Vec<arrow::array::ArrayRef> = batch
959                .columns()
960                .iter()
961                .map(|col| arrow::compute::take(col.as_ref(), &index_array, None))
962                .collect::<Result<_, _>>()?;
963            result.push(RecordBatch::try_new(schema.clone(), deduped_columns)?);
964        }
965    }
966
967    Ok(result)
968}
969
970/// Combines multiple Parquet files using object store with storage options
971///
972/// # Errors
973///
974/// Returns an error if file reading or writing fails.
975pub async fn combine_parquet_files(
976    file_paths: Vec<&str>,
977    new_file_path: &str,
978    storage_options: Option<AHashMap<String, String>>,
979    compression: Option<parquet::basic::Compression>,
980    max_row_group_size: Option<usize>,
981    deduplicate: Option<bool>,
982) -> anyhow::Result<()> {
983    if file_paths.len() <= 1 {
984        return Ok(());
985    }
986
987    // Create object store from the first file path (assuming all files are in the same store)
988    let (object_store, base_path, _) =
989        create_object_store_from_path(file_paths[0], storage_options)?;
990
991    // Convert string paths to ObjectPath
992    let object_paths: Vec<ObjectPath> = file_paths
993        .iter()
994        .map(|path| {
995            if base_path.is_empty() {
996                ObjectPath::from(*path)
997            } else {
998                ObjectPath::from(format!("{base_path}/{path}"))
999            }
1000        })
1001        .collect();
1002
1003    let new_object_path = if base_path.is_empty() {
1004        ObjectPath::from(new_file_path)
1005    } else {
1006        ObjectPath::from(format!("{base_path}/{new_file_path}"))
1007    };
1008
1009    combine_parquet_files_from_object_store(
1010        object_store,
1011        object_paths,
1012        &new_object_path,
1013        compression,
1014        max_row_group_size,
1015        deduplicate,
1016    )
1017    .await
1018}
1019
1020/// Combines multiple Parquet files from object store
1021///
1022/// # Errors
1023///
1024/// Returns an error if file reading or writing fails.
1025pub async fn combine_parquet_files_from_object_store(
1026    object_store: Arc<dyn ObjectStore>,
1027    file_paths: Vec<ObjectPath>,
1028    new_file_path: &ObjectPath,
1029    compression: Option<parquet::basic::Compression>,
1030    max_row_group_size: Option<usize>,
1031    deduplicate: Option<bool>,
1032) -> anyhow::Result<()> {
1033    if file_paths.len() <= 1 {
1034        return Ok(());
1035    }
1036
1037    let mut all_batches: Vec<RecordBatch> = Vec::new();
1038    let mut schema_with_metadata: Option<Arc<arrow::datatypes::Schema>> = None;
1039    let mut schema_source: Option<&ObjectPath> = None;
1040    let mut field_metadata_sources = HashMap::new();
1041
1042    // Read all files from object store
1043    for path in &file_paths {
1044        let result: object_store::GetResult = object_store.get(path).await?;
1045        let data = result.bytes().await?;
1046        let builder = ParquetRecordBatchReaderBuilder::try_new(data)?;
1047
1048        let candidate_schema = builder.schema().clone();
1049        schema_with_metadata = Some(
1050            if let (Some(schema), Some(source)) = (&schema_with_metadata, schema_source) {
1051                let reconciled = reconcile_consolidation_schema_with_sources(
1052                    schema,
1053                    source,
1054                    &field_metadata_sources,
1055                    &candidate_schema,
1056                    path,
1057                )?;
1058
1059                if reconciled.schema_source == ConsolidationSchemaSource::Candidate {
1060                    schema_source = Some(path);
1061                }
1062
1063                for key in reconciled.candidate_field_metadata {
1064                    field_metadata_sources.insert(key, path.clone());
1065                }
1066
1067                reconciled.schema
1068            } else {
1069                schema_source = Some(path);
1070                field_metadata_sources
1071                    .extend(field_metadata_keys(&candidate_schema).map(|key| (key, path.clone())));
1072                candidate_schema
1073            },
1074        );
1075
1076        let mut reader = builder.build()?;
1077
1078        for batch in reader.by_ref() {
1079            all_batches.push(batch?);
1080        }
1081    }
1082
1083    // Re-apply the preserved schema metadata to all collected batches so that
1084    // write_batches_to_object_store (which uses batches[0].schema()) can encode
1085    // the correct Arrow schema metadata into the combined output file.
1086    if let Some(schema) = &schema_with_metadata {
1087        all_batches = all_batches
1088            .into_iter()
1089            .map(|b| RecordBatch::try_new(schema.clone(), b.columns().to_vec()))
1090            .collect::<Result<Vec<_>, _>>()?;
1091    }
1092
1093    // Deduplicate rows if requested
1094    let batches_to_write = if deduplicate.unwrap_or(false) {
1095        deduplicate_record_batches(&all_batches)?
1096    } else {
1097        all_batches
1098    };
1099
1100    // Write combined batches to new location
1101    write_batches_to_object_store(
1102        &batches_to_write,
1103        object_store.clone(),
1104        new_file_path,
1105        compression,
1106        max_row_group_size,
1107        None,
1108    )
1109    .await?;
1110
1111    // Remove the merged files
1112    for path in &file_paths {
1113        if path != new_file_path {
1114            object_store.delete(path).await?;
1115        }
1116    }
1117
1118    Ok(())
1119}
1120
1121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1122enum ConsolidationSchemaSource {
1123    Current,
1124    Candidate,
1125}
1126
1127#[derive(Debug)]
1128struct ReconciledConsolidationSchema {
1129    schema: Arc<Schema>,
1130    schema_source: ConsolidationSchemaSource,
1131    candidate_field_metadata: Vec<(String, String)>,
1132}
1133
1134#[cfg(test)]
1135fn reconcile_consolidation_schema(
1136    current: &Arc<Schema>,
1137    current_path: &ObjectPath,
1138    candidate: &Arc<Schema>,
1139    candidate_path: &ObjectPath,
1140) -> anyhow::Result<ReconciledConsolidationSchema> {
1141    let field_metadata_sources = field_metadata_keys(current)
1142        .map(|key| (key, current_path.clone()))
1143        .collect();
1144    reconcile_consolidation_schema_with_sources(
1145        current,
1146        current_path,
1147        &field_metadata_sources,
1148        candidate,
1149        candidate_path,
1150    )
1151}
1152
1153fn reconcile_consolidation_schema_with_sources(
1154    current: &Arc<Schema>,
1155    current_path: &ObjectPath,
1156    current_field_metadata_sources: &HashMap<(String, String), ObjectPath>,
1157    candidate: &Arc<Schema>,
1158    candidate_path: &ObjectPath,
1159) -> anyhow::Result<ReconciledConsolidationSchema> {
1160    anyhow::ensure!(
1161        current.fields().len() == candidate.fields().len(),
1162        "Cannot consolidate Parquet files {current_path} and {candidate_path}: field schemas differ"
1163    );
1164    let mut candidate_field_metadata = Vec::new();
1165    let fields = current
1166        .fields()
1167        .iter()
1168        .zip(candidate.fields())
1169        .map(|(current, candidate)| {
1170            anyhow::ensure!(
1171                current.name() == candidate.name()
1172                    && current.data_type() == candidate.data_type()
1173                    && current.is_nullable() == candidate.is_nullable(),
1174                "Cannot consolidate Parquet files {current_path} and {candidate_path}: field schemas differ"
1175            );
1176            let mut metadata = current.metadata().clone();
1177            for (key, value) in candidate.metadata() {
1178                if let Some(current_value) = metadata.get(key) {
1179                    let source = current_field_metadata_sources
1180                        .get(&(current.name().clone(), key.clone()))
1181                        .unwrap_or(current_path);
1182                    anyhow::ensure!(
1183                        current_value == value,
1184                        "Cannot consolidate Parquet files {source} and {candidate_path}: field '{}' metadata differs",
1185                        current.name(),
1186                    );
1187                } else {
1188                    metadata.insert(key.clone(), value.clone());
1189                    candidate_field_metadata.push((current.name().clone(), key.clone()));
1190                }
1191            }
1192            Ok(Arc::new(current.as_ref().clone().with_metadata(metadata)))
1193        })
1194        .collect::<anyhow::Result<Vec<_>>>()?;
1195
1196    let schema_with_fields =
1197        |metadata| Arc::new(Schema::new_with_metadata(fields.clone(), metadata));
1198
1199    if current.metadata() == candidate.metadata() {
1200        return Ok(ReconciledConsolidationSchema {
1201            schema: schema_with_fields(current.metadata().clone()),
1202            schema_source: ConsolidationSchemaSource::Current,
1203            candidate_field_metadata,
1204        });
1205    }
1206
1207    let without_precision = |schema: &Schema| {
1208        let mut metadata = schema.metadata().clone();
1209        metadata.remove(KEY_PRICE_PRECISION);
1210        metadata.remove(KEY_SIZE_PRECISION);
1211        metadata
1212    };
1213    let current_metadata = current.metadata();
1214    let candidate_metadata = candidate.metadata();
1215    let current_fallback = current_metadata
1216        .get(KEY_PRICE_PRECISION)
1217        .map(String::as_str)
1218        == Some("0")
1219        && current_metadata.get(KEY_SIZE_PRECISION).map(String::as_str) == Some("0");
1220    let current_has_precision = current_metadata.contains_key(KEY_PRICE_PRECISION)
1221        && current_metadata.contains_key(KEY_SIZE_PRECISION);
1222    let candidate_fallback = candidate_metadata
1223        .get(KEY_PRICE_PRECISION)
1224        .map(String::as_str)
1225        == Some("0")
1226        && candidate_metadata
1227            .get(KEY_SIZE_PRECISION)
1228            .map(String::as_str)
1229            == Some("0");
1230    let candidate_has_precision = candidate_metadata.contains_key(KEY_PRICE_PRECISION)
1231        && candidate_metadata.contains_key(KEY_SIZE_PRECISION);
1232
1233    if without_precision(current) == without_precision(candidate) {
1234        match (current_fallback, candidate_fallback) {
1235            (true, false) if candidate_has_precision => {
1236                return Ok(ReconciledConsolidationSchema {
1237                    schema: schema_with_fields(candidate.metadata().clone()),
1238                    schema_source: ConsolidationSchemaSource::Candidate,
1239                    candidate_field_metadata,
1240                });
1241            }
1242            (false, true) if current_has_precision => {
1243                return Ok(ReconciledConsolidationSchema {
1244                    schema: schema_with_fields(current.metadata().clone()),
1245                    schema_source: ConsolidationSchemaSource::Current,
1246                    candidate_field_metadata,
1247                });
1248            }
1249            _ => {}
1250        }
1251    }
1252
1253    anyhow::bail!(
1254        "Cannot consolidate Parquet files {current_path} and {candidate_path}: schema metadata differs: {current_metadata:?} versus {candidate_metadata:?}"
1255    )
1256}
1257
1258fn field_metadata_keys(schema: &Schema) -> impl Iterator<Item = (String, String)> + '_ {
1259    schema.fields().iter().flat_map(|field| {
1260        field
1261            .metadata()
1262            .keys()
1263            .map(|key| (field.name().clone(), key.clone()))
1264    })
1265}
1266
1267/// Extracts the minimum and maximum i64 values for the specified `column_name` from a Parquet file's metadata using object store with storage options.
1268///
1269/// # Errors
1270///
1271/// Returns an error if the file cannot be read, metadata parsing fails, or the column is missing or has no statistics.
1272pub async fn min_max_from_parquet_metadata(
1273    file_path: &str,
1274    storage_options: Option<AHashMap<String, String>>,
1275    column_name: &str,
1276) -> anyhow::Result<(u64, u64)> {
1277    let (object_store, base_path, _) = create_object_store_from_path(file_path, storage_options)?;
1278    let object_path = if base_path.is_empty() {
1279        ObjectPath::from(file_path)
1280    } else {
1281        ObjectPath::from(format!("{base_path}/{file_path}"))
1282    };
1283
1284    min_max_from_parquet_metadata_object_store(object_store, &object_path, column_name).await
1285}
1286
1287/// Extracts the minimum and maximum i64 values for the specified `column_name` from a Parquet file's metadata in object store.
1288///
1289/// # Errors
1290///
1291/// Returns an error if the file cannot be read, metadata parsing fails, or the column is missing or has no statistics.
1292pub async fn min_max_from_parquet_metadata_object_store(
1293    object_store: Arc<dyn ObjectStore>,
1294    file_path: &ObjectPath,
1295    column_name: &str,
1296) -> anyhow::Result<(u64, u64)> {
1297    // Download the parquet file from object store
1298    let result: object_store::GetResult = object_store.get(file_path).await?;
1299    let data = result.bytes().await?;
1300    let reader = SerializedFileReader::new(data)?;
1301
1302    let metadata = reader.metadata();
1303    let mut overall_min_value: Option<i64> = None;
1304    let mut overall_max_value: Option<i64> = None;
1305
1306    // Iterate through all row groups
1307    for i in 0..metadata.num_row_groups() {
1308        let row_group = metadata.row_group(i);
1309
1310        // Iterate through all columns in this row group
1311        for j in 0..row_group.num_columns() {
1312            let col_metadata = row_group.column(j);
1313
1314            if col_metadata.column_path().string() == column_name {
1315                if let Some(stats) = col_metadata.statistics() {
1316                    // Check if we have Int64 statistics
1317                    if let Statistics::Int64(int64_stats) = stats {
1318                        // Extract min value if available
1319                        if let Some(&min_value) = int64_stats.min_opt()
1320                            && (overall_min_value.is_none()
1321                                || min_value < overall_min_value.unwrap())
1322                        {
1323                            overall_min_value = Some(min_value);
1324                        }
1325
1326                        // Extract max value if available
1327                        if let Some(&max_value) = int64_stats.max_opt()
1328                            && (overall_max_value.is_none()
1329                                || max_value > overall_max_value.unwrap())
1330                        {
1331                            overall_max_value = Some(max_value);
1332                        }
1333                    } else {
1334                        anyhow::bail!("Warning: Column name '{column_name}' is not of type i64.");
1335                    }
1336                } else {
1337                    anyhow::bail!(
1338                        "Warning: Statistics not available for column '{column_name}' in row group {i}."
1339                    );
1340                }
1341            }
1342        }
1343    }
1344
1345    // Return the min/max pair if both are available
1346    if let (Some(min), Some(max)) = (overall_min_value, overall_max_value) {
1347        Ok((u64::try_from(min)?, u64::try_from(max)?))
1348    } else {
1349        anyhow::bail!(
1350            "Column '{column_name}' not found or has no Int64 statistics in any row group."
1351        )
1352    }
1353}
1354
1355/// Creates an object store from a URI string with optional storage options.
1356///
1357/// Supports multiple cloud storage providers:
1358/// - AWS S3: `s3://bucket/path`
1359/// - Google Cloud Storage: `gs://bucket/path` or `gcs://bucket/path`
1360/// - Azure Blob Storage: `az://account/container/path` or `abfs://container@account.dfs.core.windows.net/path`
1361/// - HTTP/WebDAV: `http://` or `https://`
1362/// - Local files: `file://path` or plain paths
1363///
1364/// # Parameters
1365///
1366/// - `path`: The URI string for the storage location.
1367/// - `storage_options`: Optional `HashMap` containing storage-specific configuration options:
1368///   - For S3: `endpoint_url`, region, `access_key_id`, `secret_access_key`, `session_token`, etc.
1369///   - For GCS: `service_account_path`, `service_account_key`, `project_id`, etc.
1370///   - For Azure: `account_name`, `account_key`, `sas_token`, etc.
1371///
1372/// Returns a tuple of (`ObjectStore`, `base_path`, `normalized_uri`)
1373pub fn create_object_store_from_path(
1374    path: &str,
1375    storage_options: Option<AHashMap<String, String>>,
1376) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
1377    let location = create_object_store_location_from_path(path, storage_options)?;
1378    Ok((
1379        location.object_store,
1380        location.base_path,
1381        location.original_uri,
1382    ))
1383}
1384
1385// `storage_options` is only consumed by the cloud-feature arms,
1386// so keep the allow scoped to the no-cloud build.
1387#[cfg_attr(
1388    not(feature = "cloud"),
1389    allow(unused_variables, clippy::needless_pass_by_value)
1390)]
1391pub(crate) fn create_object_store_location_from_path(
1392    path: &str,
1393    storage_options: Option<AHashMap<String, String>>,
1394) -> anyhow::Result<ObjectStoreLocation> {
1395    let uri = normalize_path_to_uri(path)?;
1396
1397    let (object_store, base_path, original_uri) = match uri.as_str() {
1398        #[cfg(feature = "cloud")]
1399        s if s.starts_with("s3://") => create_s3_store(&uri, storage_options),
1400        #[cfg(feature = "cloud")]
1401        s if s.starts_with("gs://") || s.starts_with("gcs://") => {
1402            create_gcs_store(&uri, storage_options)
1403        }
1404        #[cfg(feature = "cloud")]
1405        s if s.starts_with("az://") => create_azure_store(&uri, storage_options),
1406        #[cfg(feature = "cloud")]
1407        s if s.starts_with("abfs://") => create_abfs_store(&uri, storage_options),
1408        #[cfg(feature = "cloud")]
1409        s if s.starts_with("http://") || s.starts_with("https://") => {
1410            create_http_store(&uri, storage_options)
1411        }
1412        #[cfg(not(feature = "cloud"))]
1413        s if s.starts_with("s3://")
1414            || s.starts_with("gs://")
1415            || s.starts_with("gcs://")
1416            || s.starts_with("az://")
1417            || s.starts_with("abfs://")
1418            || s.starts_with("http://")
1419            || s.starts_with("https://") =>
1420        {
1421            anyhow::bail!("Cloud storage support requires the 'cloud' feature: {uri}")
1422        }
1423        s if s.starts_with("file://") => create_local_store(&uri, true),
1424        _ => create_local_store(&uri, false), // Fallback: assume local path
1425    }?;
1426
1427    let store_root_url = Url::parse(&original_uri)
1428        .ok()
1429        .filter(|url| is_remote_uri_scheme(url.scheme()))
1430        .map(|_| remote_store_root_url(&original_uri))
1431        .transpose()?;
1432    Ok(ObjectStoreLocation {
1433        object_store,
1434        base_path,
1435        original_uri,
1436        store_root_url,
1437    })
1438}
1439
1440pub(crate) use crate::common::paths::file_uri_to_native_path;
1441pub use crate::common::paths::normalize_path_to_uri;
1442
1443/// Appends an encoded object-store path to the local storage URI.
1444/// Preserve the encoded names used by the native object-store backend.
1445pub(crate) fn append_path_to_file_uri(base_uri: &str, path: &str) -> String {
1446    if let Ok(mut url) = Url::parse(base_uri) {
1447        if let Ok(mut segments) = url.path_segments_mut() {
1448            segments.pop_if_empty();
1449            segments.extend(
1450                path.trim_end_matches('/')
1451                    .split('/')
1452                    .filter(|segment| !segment.is_empty()),
1453            );
1454        }
1455        return url.to_string();
1456    }
1457
1458    format!(
1459        "{}/{}",
1460        base_uri.trim_end_matches('/'),
1461        path.trim_end_matches('/')
1462    )
1463}
1464
1465/// Decodes a percent-encoded `object_store` path segment back to its logical form.
1466///
1467/// `object_store` lists path segments in URL-encoded form, so a non-ASCII instrument
1468/// directory reads back with each non-ASCII byte as a `%XX` sequence. Decoding recovers the
1469/// original id for matching against `urisafe_instrument_id`. Returns the input unchanged when
1470/// it is not valid percent-encoded UTF-8.
1471pub(crate) fn decode_object_store_segment(segment: &str) -> String {
1472    object_store::path::Path::from_url_path(segment)
1473        .map_or_else(|_| segment.to_string(), String::from)
1474}
1475
1476fn create_local_store(
1477    uri: &str,
1478    is_file_uri: bool,
1479) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
1480    let path = if is_file_uri {
1481        file_uri_to_native_path(uri)
1482    } else {
1483        uri.to_string()
1484    };
1485
1486    let local_store =
1487        object_store::local::LocalFileSystem::new_with_prefix(&path).with_context(|| {
1488            format!(
1489                "failed to open local storage directory '{path}'; \
1490             create it if it does not exist and check access permissions"
1491            )
1492        })?;
1493
1494    Ok((Arc::new(local_store), String::new(), uri.to_string()))
1495}
1496
1497/// Helper function to create S3 object store with options.
1498#[cfg(feature = "cloud")]
1499fn create_s3_store(
1500    uri: &str,
1501    storage_options: Option<AHashMap<String, String>>,
1502) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
1503    let (url, path) = parse_url_and_path(uri)?;
1504    let bucket = extract_host(&url, "Invalid S3 URI: missing bucket")?;
1505
1506    let mut builder = object_store::aws::AmazonS3Builder::new().with_bucket_name(&bucket);
1507
1508    // Apply storage options if provided
1509    if let Some(options) = storage_options {
1510        for (key, value) in options {
1511            match key.as_str() {
1512                // Accept legacy storage-option aliases alongside native names.
1513                "endpoint_url" | "endpoint" => {
1514                    builder = builder.with_endpoint(&value);
1515                }
1516                "region" => {
1517                    builder = builder.with_region(&value);
1518                }
1519                "access_key_id" | "key" => {
1520                    builder = builder.with_access_key_id(&value);
1521                }
1522                "secret_access_key" | "secret" => {
1523                    builder = builder.with_secret_access_key(&value);
1524                }
1525                "session_token" | "token" => {
1526                    builder = builder.with_token(&value);
1527                }
1528                "allow_http" => {
1529                    let allow_http = value.to_lowercase() == "true";
1530                    builder = builder.with_allow_http(allow_http);
1531                }
1532                _ => {
1533                    // Ignore unknown options for forward compatibility
1534                    log::warn!("Unknown S3 storage option: {key}");
1535                }
1536            }
1537        }
1538    }
1539
1540    let s3_store = builder.build()?;
1541    Ok((Arc::new(s3_store), path, uri.to_string()))
1542}
1543
1544/// Helper function to create GCS object store with options.
1545#[cfg(feature = "cloud")]
1546fn create_gcs_store(
1547    uri: &str,
1548    storage_options: Option<AHashMap<String, String>>,
1549) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
1550    let (url, path) = parse_url_and_path(uri)?;
1551    let bucket = extract_host(&url, "Invalid GCS URI: missing bucket")?;
1552
1553    let mut builder = object_store::gcp::GoogleCloudStorageBuilder::new().with_bucket_name(&bucket);
1554
1555    // Apply storage options if provided
1556    if let Some(options) = storage_options {
1557        for (key, value) in options {
1558            match key.as_str() {
1559                "service_account_path" | "credential_path" => {
1560                    builder = builder.with_service_account_path(&value);
1561                }
1562                "service_account_key" => {
1563                    builder = builder.with_service_account_key(&value);
1564                }
1565                "project_id" => {
1566                    // Note: GoogleCloudStorageBuilder doesn't have with_project_id method
1567                    // This would need to be handled via environment variables or service account
1568                    log::warn!(
1569                        "project_id should be set via service account or environment variables"
1570                    );
1571                }
1572                "application_credentials" => {
1573                    // Set GOOGLE_APPLICATION_CREDENTIALS env var required by Google auth libraries.
1574                    // SAFETY: std::env::set_var is marked unsafe because it mutates global state and
1575                    // can break signal-safe code. We only call it during configuration before any
1576                    // multi-threaded work starts, so it is considered safe in this context.
1577                    unsafe {
1578                        std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", &value);
1579                    }
1580                }
1581                _ => {
1582                    // Ignore unknown options for forward compatibility
1583                    log::warn!("Unknown GCS storage option: {key}");
1584                }
1585            }
1586        }
1587    }
1588
1589    let gcs_store = builder.build()?;
1590    Ok((Arc::new(gcs_store), path, uri.to_string()))
1591}
1592
1593/// Helper function to create Azure object store with options.
1594#[cfg(feature = "cloud")]
1595fn create_azure_store(
1596    uri: &str,
1597    storage_options: Option<AHashMap<String, String>>,
1598) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
1599    let (url, _) = parse_url_and_path(uri)?;
1600    let container = extract_host(&url, "Invalid Azure URI: missing container")?;
1601
1602    let path = url.path().trim_start_matches('/').to_string();
1603
1604    let mut builder =
1605        object_store::azure::MicrosoftAzureBuilder::new().with_container_name(container);
1606
1607    // Apply storage options if provided
1608    if let Some(options) = storage_options {
1609        builder = apply_azure_storage_options(builder, options, "Azure");
1610    }
1611
1612    let azure_store = builder.build()?;
1613    Ok((Arc::new(azure_store), path, uri.to_string()))
1614}
1615
1616/// Helper function to create Azure object store from abfs:// URI with options.
1617#[cfg(feature = "cloud")]
1618fn create_abfs_store(
1619    uri: &str,
1620    storage_options: Option<AHashMap<String, String>>,
1621) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
1622    let (url, path) = parse_url_and_path(uri)?;
1623    let host = extract_host(&url, "Invalid ABFS URI: missing host")?;
1624
1625    // Extract account from host (account.dfs.core.windows.net)
1626    let account = host
1627        .split('.')
1628        .next()
1629        .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: cannot extract account from host"))?;
1630
1631    // Extract container from username part
1632    let container = url
1633        .username()
1634        .split('@')
1635        .next()
1636        .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: missing container"))?;
1637
1638    let mut builder = object_store::azure::MicrosoftAzureBuilder::new()
1639        .with_account(account)
1640        .with_container_name(container);
1641
1642    // Apply storage options if provided (same as Azure store)
1643    if let Some(options) = storage_options {
1644        builder = apply_azure_storage_options(builder, options, "ABFS");
1645    }
1646
1647    let azure_store = builder.build()?;
1648    Ok((Arc::new(azure_store), path, uri.to_string()))
1649}
1650
1651/// Applies shared Azure storage options to the builder; `store_label` names the URI
1652/// scheme ("Azure" or "ABFS") in unknown-option warnings.
1653#[cfg(feature = "cloud")]
1654fn apply_azure_storage_options(
1655    mut builder: object_store::azure::MicrosoftAzureBuilder,
1656    options: AHashMap<String, String>,
1657    store_label: &str,
1658) -> object_store::azure::MicrosoftAzureBuilder {
1659    for (key, value) in options {
1660        match key.as_str() {
1661            "account_name" => {
1662                builder = builder.with_account(&value);
1663            }
1664            "account_key" => {
1665                builder = builder.with_access_key(&value);
1666            }
1667            "sas_token" => {
1668                // Parse SAS token as query string parameters
1669                let query_pairs: Vec<(String, String)> = value
1670                    .split('&')
1671                    .filter_map(|pair| {
1672                        let mut parts = pair.split('=');
1673                        match (parts.next(), parts.next()) {
1674                            (Some(key), Some(val)) => Some((key.to_string(), val.to_string())),
1675                            _ => None,
1676                        }
1677                    })
1678                    .collect();
1679                builder = builder.with_sas_authorization(query_pairs);
1680            }
1681            "client_id" => {
1682                builder = builder.with_client_id(&value);
1683            }
1684            "client_secret" => {
1685                builder = builder.with_client_secret(&value);
1686            }
1687            "tenant_id" => {
1688                builder = builder.with_tenant_id(&value);
1689            }
1690            _ => {
1691                // Ignore unknown options for forward compatibility
1692                log::warn!("Unknown {store_label} storage option: {key}");
1693            }
1694        }
1695    }
1696
1697    builder
1698}
1699
1700/// Helper function to create HTTP object store with options.
1701#[cfg(feature = "cloud")]
1702fn create_http_store(
1703    uri: &str,
1704    storage_options: Option<AHashMap<String, String>>,
1705) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
1706    let (_, path) = parse_url_and_path(uri)?;
1707    let base_url = remote_store_root_url(uri)?
1708        .as_str()
1709        .trim_end_matches('/')
1710        .to_string();
1711
1712    let builder = object_store::http::HttpBuilder::new().with_url(base_url);
1713
1714    // Apply storage options if provided
1715    if let Some(options) = storage_options {
1716        for (key, _value) in options {
1717            // HTTP builder has limited configuration options
1718            // Most HTTP-specific options would be handled via client options
1719            // Ignore unknown options for forward compatibility
1720            log::warn!("Unknown HTTP storage option: {key}");
1721        }
1722    }
1723
1724    let http_store = builder.build()?;
1725    Ok((Arc::new(http_store), path, uri.to_string()))
1726}
1727
1728/// Helper function to parse URL and extract path component.
1729#[cfg(feature = "cloud")]
1730fn parse_url_and_path(uri: &str) -> anyhow::Result<(url::Url, String)> {
1731    let url = url::Url::parse(uri)?;
1732    let path = url.path().trim_start_matches('/').to_string();
1733    Ok((url, path))
1734}
1735
1736/// Helper function to extract host from URL with error handling.
1737#[cfg(feature = "cloud")]
1738fn extract_host(url: &url::Url, error_msg: &str) -> anyhow::Result<String> {
1739    url.host_str()
1740        .map(ToString::to_string)
1741        .ok_or_else(|| anyhow::anyhow!("{error_msg}"))
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746    use std::{collections::HashMap, sync::Arc};
1747
1748    #[cfg(feature = "cloud")]
1749    use ahash::AHashMap;
1750    use arrow::{
1751        array::{
1752            ArrayRef, FixedSizeBinaryArray, ListArray, StringArray, StringDictionaryBuilder,
1753            UInt8Array, UInt32Array, UInt64Array,
1754        },
1755        datatypes::{DataType, Field, Int8Type, Schema},
1756    };
1757    use nautilus_serialization::arrow::json_string_field;
1758    use parquet::file::{properties::ReaderProperties, serialized_reader::ReadOptionsBuilder};
1759    use rstest::rstest;
1760
1761    use super::*;
1762
1763    fn consolidation_depth_schema(
1764        price_precision: &str,
1765        size_precision: &str,
1766        instrument_id: &str,
1767    ) -> Arc<Schema> {
1768        Arc::new(Schema::new_with_metadata(
1769            vec![Field::new("bids", DataType::Utf8, false)],
1770            HashMap::from([
1771                (KEY_PRICE_PRECISION.to_string(), price_precision.to_string()),
1772                (KEY_SIZE_PRECISION.to_string(), size_precision.to_string()),
1773                (KEY_IDENTIFIER.to_string(), instrument_id.to_string()),
1774            ]),
1775        ))
1776    }
1777
1778    #[rstest]
1779    fn consolidation_schema_prefers_populated_depth_precision_in_either_order() {
1780        let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1781        let populated = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1782        let fallback_path = ObjectPath::from("empty.parquet");
1783        let populated_path = ObjectPath::from("populated.parquet");
1784
1785        let fallback_first =
1786            reconcile_consolidation_schema(&fallback, &fallback_path, &populated, &populated_path)
1787                .unwrap();
1788        let populated_first =
1789            reconcile_consolidation_schema(&populated, &populated_path, &fallback, &fallback_path)
1790                .unwrap();
1791
1792        assert_eq!(
1793            fallback_first.schema_source,
1794            ConsolidationSchemaSource::Candidate,
1795        );
1796        assert_eq!(
1797            populated_first.schema_source,
1798            ConsolidationSchemaSource::Current,
1799        );
1800
1801        for schema in [fallback_first.schema, populated_first.schema] {
1802            assert_eq!(schema.metadata()[KEY_PRICE_PRECISION], "2");
1803            assert_eq!(schema.metadata()[KEY_SIZE_PRECISION], "3");
1804        }
1805    }
1806
1807    #[rstest]
1808    fn consolidation_schema_rejects_other_metadata_mismatches() {
1809        let current = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1810        let candidate = consolidation_depth_schema("2", "3", "BTCUSDT.BINANCE");
1811        let current_path = ObjectPath::from("eth.parquet");
1812        let candidate_path = ObjectPath::from("btc.parquet");
1813
1814        let error =
1815            reconcile_consolidation_schema(&current, &current_path, &candidate, &candidate_path)
1816                .unwrap_err();
1817
1818        assert!(error.to_string().contains("eth.parquet and btc.parquet"));
1819        assert!(error.to_string().contains("ETHUSDT.BINANCE"));
1820        assert!(error.to_string().contains("BTCUSDT.BINANCE"));
1821    }
1822
1823    #[rstest]
1824    fn consolidation_schema_rejects_two_populated_precisions() {
1825        let current = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1826        let candidate = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
1827        let current_path = ObjectPath::from("precision-2.parquet");
1828        let candidate_path = ObjectPath::from("precision-4.parquet");
1829
1830        let error =
1831            reconcile_consolidation_schema(&current, &current_path, &candidate, &candidate_path)
1832                .unwrap_err();
1833
1834        assert!(error.to_string().contains("precision-2.parquet"));
1835        assert!(error.to_string().contains("precision-4.parquet"));
1836    }
1837
1838    #[rstest]
1839    fn consolidation_schema_keeps_fallback_for_all_empty_files() {
1840        let first = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1841        let second = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1842
1843        let reconciled = reconcile_consolidation_schema(
1844            &first,
1845            &ObjectPath::from("first-empty.parquet"),
1846            &second,
1847            &ObjectPath::from("second-empty.parquet"),
1848        )
1849        .unwrap();
1850
1851        assert_eq!(reconciled.schema.metadata()[KEY_PRICE_PRECISION], "0");
1852        assert_eq!(reconciled.schema.metadata()[KEY_SIZE_PRECISION], "0");
1853    }
1854
1855    #[rstest]
1856    fn consolidation_schema_rejects_missing_precision_against_fallback() {
1857        let missing = Arc::new(Schema::new_with_metadata(
1858            vec![Field::new("bids", DataType::Utf8, false)],
1859            HashMap::from([(KEY_IDENTIFIER.to_string(), "ETHUSDT.BINANCE".to_string())]),
1860        ));
1861        let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1862
1863        let error = reconcile_consolidation_schema(
1864            &missing,
1865            &ObjectPath::from("missing.parquet"),
1866            &fallback,
1867            &ObjectPath::from("fallback.parquet"),
1868        )
1869        .unwrap_err();
1870
1871        assert!(error.to_string().contains("schema metadata differs"));
1872    }
1873
1874    #[rstest]
1875    fn normalize_legacy_info_schema_makes_binary_info_nullable() {
1876        let schema = Schema::new(vec![Field::new("info", DataType::Binary, false)]);
1877
1878        let normalized = normalize_legacy_parquet_schema(&schema);
1879
1880        let info = normalized.field_with_name("info").unwrap();
1881        assert_eq!(info.data_type(), &DataType::Utf8);
1882        assert!(info.is_nullable());
1883    }
1884
1885    #[rstest]
1886    fn consolidation_schema_merges_json_field_annotation_in_either_order() {
1887        let bare = Arc::new(Schema::new(vec![Field::new("info", DataType::Utf8, true)]));
1888        let annotated = Arc::new(Schema::new(vec![json_string_field("info", true)]));
1889        let bare_path = ObjectPath::from("bare.parquet");
1890        let annotated_path = ObjectPath::from("annotated.parquet");
1891
1892        let bare_first =
1893            reconcile_consolidation_schema(&bare, &bare_path, &annotated, &annotated_path).unwrap();
1894        let annotated_first =
1895            reconcile_consolidation_schema(&annotated, &annotated_path, &bare, &bare_path).unwrap();
1896
1897        assert_eq!(bare_first.schema, annotated_first.schema);
1898        assert_eq!(
1899            bare_first.schema.field_with_name("info").unwrap(),
1900            &json_string_field("info", true),
1901        );
1902    }
1903
1904    #[rstest]
1905    fn consolidation_field_metadata_conflict_names_the_winning_file() {
1906        let bare = Arc::new(Schema::new(vec![Field::new("info", DataType::Utf8, true)]));
1907        let annotated = Arc::new(Schema::new(vec![json_string_field("info", true)]));
1908        let conflicting = Arc::new(Schema::new(vec![
1909            Field::new("info", DataType::Utf8, true).with_metadata(HashMap::from([(
1910                "ARROW:extension:name".to_string(),
1911                "other.extension".to_string(),
1912            )])),
1913        ]));
1914        let bare_path = ObjectPath::from("bare.parquet");
1915        let annotated_path = ObjectPath::from("annotated.parquet");
1916        let conflicting_path = ObjectPath::from("conflicting.parquet");
1917        let reconciled =
1918            reconcile_consolidation_schema(&bare, &bare_path, &annotated, &annotated_path).unwrap();
1919
1920        assert_eq!(reconciled.candidate_field_metadata.len(), 2);
1921        assert!(
1922            reconciled
1923                .candidate_field_metadata
1924                .contains(&("info".to_string(), "ARROW:extension:name".to_string())),
1925        );
1926        assert!(
1927            reconciled
1928                .candidate_field_metadata
1929                .contains(&("info".to_string(), "ARROW:extension:metadata".to_string())),
1930        );
1931        let field_metadata_sources = reconciled
1932            .candidate_field_metadata
1933            .iter()
1934            .cloned()
1935            .map(|key| (key, annotated_path.clone()))
1936            .collect();
1937        let error = reconcile_consolidation_schema_with_sources(
1938            &reconciled.schema,
1939            &bare_path,
1940            &field_metadata_sources,
1941            &conflicting,
1942            &conflicting_path,
1943        )
1944        .unwrap_err();
1945        assert!(error.to_string().contains("annotated.parquet"));
1946        assert!(error.to_string().contains("conflicting.parquet"));
1947        assert!(error.to_string().contains("field 'info' metadata differs"));
1948    }
1949
1950    #[rstest]
1951    fn consolidation_conflict_names_the_last_winning_schema() {
1952        let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
1953        let precision_2 = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1954        let precision_4 = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
1955        let fallback_path = ObjectPath::from("fallback.parquet");
1956        let precision_2_path = ObjectPath::from("precision-2.parquet");
1957        let precision_4_path = ObjectPath::from("precision-4.parquet");
1958        let reconciled = reconcile_consolidation_schema(
1959            &fallback,
1960            &fallback_path,
1961            &precision_2,
1962            &precision_2_path,
1963        )
1964        .unwrap();
1965
1966        let error = reconcile_consolidation_schema(
1967            &reconciled.schema,
1968            &precision_2_path,
1969            &precision_4,
1970            &precision_4_path,
1971        )
1972        .unwrap_err();
1973
1974        assert!(error.to_string().contains("precision-2.parquet"));
1975        assert!(error.to_string().contains("precision-4.parquet"));
1976    }
1977
1978    #[rstest]
1979    fn consolidation_field_metadata_does_not_replace_precision_source() {
1980        let precision_2 = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
1981        let fallback = Arc::new(Schema::new_with_metadata(
1982            vec![json_string_field("bids", false)],
1983            consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE")
1984                .metadata()
1985                .clone(),
1986        ));
1987        let precision_4 = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
1988        let precision_2_path = ObjectPath::from("precision-2.parquet");
1989        let fallback_path = ObjectPath::from("fallback-annotated.parquet");
1990        let precision_4_path = ObjectPath::from("precision-4.parquet");
1991        let reconciled = reconcile_consolidation_schema(
1992            &precision_2,
1993            &precision_2_path,
1994            &fallback,
1995            &fallback_path,
1996        )
1997        .unwrap();
1998
1999        assert_eq!(reconciled.schema_source, ConsolidationSchemaSource::Current,);
2000        assert_eq!(reconciled.candidate_field_metadata.len(), 2);
2001        assert!(
2002            reconciled
2003                .candidate_field_metadata
2004                .contains(&("bids".to_string(), "ARROW:extension:name".to_string())),
2005        );
2006        assert!(
2007            reconciled
2008                .candidate_field_metadata
2009                .contains(&("bids".to_string(), "ARROW:extension:metadata".to_string())),
2010        );
2011        let field_metadata_sources = reconciled
2012            .candidate_field_metadata
2013            .iter()
2014            .cloned()
2015            .map(|key| (key, fallback_path.clone()))
2016            .collect();
2017        let error = reconcile_consolidation_schema_with_sources(
2018            &reconciled.schema,
2019            &precision_2_path,
2020            &field_metadata_sources,
2021            &precision_4,
2022            &precision_4_path,
2023        )
2024        .unwrap_err();
2025        let message = error.to_string();
2026
2027        assert!(message.contains("precision-2.parquet"));
2028        assert!(message.contains("precision-4.parquet"));
2029        assert!(!message.contains("fallback-annotated.parquet"));
2030    }
2031
2032    #[rstest]
2033    fn consolidation_field_metadata_tracks_each_origin() {
2034        let bare = Arc::new(Schema::new(vec![
2035            Field::new("info", DataType::Utf8, true),
2036            Field::new("balances", DataType::Utf8, true),
2037        ]));
2038        let info = Arc::new(Schema::new(vec![
2039            json_string_field("info", true),
2040            Field::new("balances", DataType::Utf8, true),
2041        ]));
2042        let balances = Arc::new(Schema::new(vec![
2043            json_string_field("info", true),
2044            json_string_field("balances", true),
2045        ]));
2046        let conflicting = Arc::new(Schema::new(vec![
2047            Field::new("info", DataType::Utf8, true).with_metadata(HashMap::from([(
2048                "ARROW:extension:name".to_string(),
2049                "other.extension".to_string(),
2050            )])),
2051            json_string_field("balances", true),
2052        ]));
2053        let bare_path = ObjectPath::from("bare.parquet");
2054        let info_path = ObjectPath::from("info.parquet");
2055        let balances_path = ObjectPath::from("balances.parquet");
2056        let conflicting_path = ObjectPath::from("conflicting.parquet");
2057        let with_info =
2058            reconcile_consolidation_schema(&bare, &bare_path, &info, &info_path).unwrap();
2059        let mut field_metadata_sources = with_info
2060            .candidate_field_metadata
2061            .iter()
2062            .cloned()
2063            .map(|key| (key, info_path.clone()))
2064            .collect::<HashMap<_, _>>();
2065        let with_balances = reconcile_consolidation_schema_with_sources(
2066            &with_info.schema,
2067            &bare_path,
2068            &field_metadata_sources,
2069            &balances,
2070            &balances_path,
2071        )
2072        .unwrap();
2073
2074        for key in with_balances.candidate_field_metadata {
2075            field_metadata_sources.insert(key, balances_path.clone());
2076        }
2077
2078        let error = reconcile_consolidation_schema_with_sources(
2079            &with_balances.schema,
2080            &bare_path,
2081            &field_metadata_sources,
2082            &conflicting,
2083            &conflicting_path,
2084        )
2085        .unwrap_err();
2086        let message = error.to_string();
2087
2088        assert!(message.contains("info.parquet"));
2089        assert!(message.contains("conflicting.parquet"));
2090        assert!(!message.contains("balances.parquet"));
2091    }
2092
2093    #[rstest]
2094    fn normalize_dictionary_string_columns_casts_string_dictionaries_to_utf8() {
2095        let mut builder = StringDictionaryBuilder::<Int8Type>::new();
2096        builder.append("AUD/USD.SIM").unwrap();
2097        builder.append("EUR/USD.SIM").unwrap();
2098        let dictionary = Arc::new(builder.finish()) as ArrayRef;
2099        let schema = Arc::new(Schema::new(vec![
2100            Field::new("instrument_id", dictionary.data_type().clone(), false),
2101            Field::new("ts_init", DataType::UInt64, false),
2102        ]));
2103        let batch = RecordBatch::try_new(
2104            schema,
2105            vec![
2106                dictionary,
2107                Arc::new(UInt64Array::from(vec![1_u64, 2])) as ArrayRef,
2108            ],
2109        )
2110        .unwrap();
2111
2112        let normalized = normalize_dictionary_string_columns(&batch).unwrap();
2113
2114        assert_eq!(
2115            normalized
2116                .schema()
2117                .field_with_name("instrument_id")
2118                .unwrap()
2119                .data_type(),
2120            &DataType::Utf8,
2121        );
2122        let values = normalized
2123            .column_by_name("instrument_id")
2124            .unwrap()
2125            .as_any()
2126            .downcast_ref::<StringArray>()
2127            .unwrap()
2128            .iter()
2129            .collect::<Vec<_>>();
2130        assert_eq!(values, vec![Some("AUD/USD.SIM"), Some("EUR/USD.SIM")]);
2131    }
2132
2133    #[rstest]
2134    fn normalize_legacy_parquet_columns_preserves_unrecognized_dictionary() {
2135        let mut builder = StringDictionaryBuilder::<Int8Type>::new();
2136        builder.append("alpha").unwrap();
2137        let dictionary = Arc::new(builder.finish()) as ArrayRef;
2138        let schema = Arc::new(Schema::new(vec![Field::new(
2139            "label",
2140            dictionary.data_type().clone(),
2141            false,
2142        )]));
2143        let batch = RecordBatch::try_new(schema, vec![dictionary]).unwrap();
2144
2145        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2146
2147        assert_eq!(normalized, batch);
2148    }
2149
2150    #[rstest]
2151    fn normalize_open_custom_columns_preserves_dictionary_with_type_metadata() {
2152        let mut builder = StringDictionaryBuilder::<Int8Type>::new();
2153        builder.append("alpha").unwrap();
2154        let dictionary = Arc::new(builder.finish()) as ArrayRef;
2155        let decimal = Arc::new(
2156            Decimal128Array::from(vec![Some(123_i128)])
2157                .with_precision_and_scale(38, 16)
2158                .unwrap(),
2159        ) as ArrayRef;
2160        let schema = Arc::new(Schema::new_with_metadata(
2161            vec![
2162                Field::new("label", dictionary.data_type().clone(), false),
2163                Field::new("price", decimal.data_type().clone(), false),
2164                Field::new("ts_recv", DataType::UInt64, false),
2165            ],
2166            HashMap::from([("type_name".to_string(), "CustomData".to_string())]),
2167        ));
2168        let batch = RecordBatch::try_new(
2169            schema,
2170            vec![dictionary, decimal, Arc::new(UInt64Array::from(vec![7]))],
2171        )
2172        .unwrap();
2173
2174        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2175
2176        assert_eq!(normalized, batch);
2177    }
2178
2179    #[rstest]
2180    fn normalize_legacy_parquet_schema_preserves_unrecognized_fixed_binary() {
2181        let schema = Schema::new(vec![Field::new(
2182            "price",
2183            DataType::FixedSizeBinary(8),
2184            false,
2185        )]);
2186
2187        let normalized = normalize_legacy_parquet_schema(&schema);
2188
2189        assert_eq!(normalized, schema);
2190    }
2191
2192    #[rstest]
2193    fn normalize_legacy_parquet_columns_converts_binary_info_null_to_arrow_null() {
2194        let schema = Arc::new(Schema::new(vec![
2195            Field::new("info", DataType::Binary, true),
2196            Field::new("ts_init", DataType::UInt64, false),
2197        ]));
2198        let batch = RecordBatch::try_new(
2199            schema,
2200            vec![
2201                Arc::new(BinaryArray::from_vec(vec![b"null".as_slice()])) as ArrayRef,
2202                Arc::new(UInt64Array::from(vec![1_u64])) as ArrayRef,
2203            ],
2204        )
2205        .unwrap();
2206
2207        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2208        let info = normalized
2209            .column_by_name("info")
2210            .unwrap()
2211            .as_any()
2212            .downcast_ref::<StringArray>()
2213            .unwrap();
2214
2215        assert_eq!(
2216            normalized
2217                .schema()
2218                .field_with_name("info")
2219                .unwrap()
2220                .data_type(),
2221            &DataType::Utf8,
2222        );
2223        assert!(info.is_null(0));
2224    }
2225
2226    #[rstest]
2227    fn normalize_legacy_parquet_columns_preserves_quote_price_columns() {
2228        let decimal = DataType::Decimal128(38, 16);
2229        let schema = Arc::new(Schema::new(vec![
2230            Field::new("bid_price", decimal.clone(), false),
2231            Field::new("ask_price", decimal.clone(), false),
2232            Field::new("bid_size", decimal.clone(), false),
2233            Field::new("ask_size", decimal, false),
2234        ]));
2235        let values = || {
2236            Arc::new(
2237                Decimal128Array::from(vec![1_i128])
2238                    .with_precision_and_scale(38, 16)
2239                    .unwrap(),
2240            ) as ArrayRef
2241        };
2242        let batch =
2243            RecordBatch::try_new(schema.clone(), vec![values(), values(), values(), values()])
2244                .unwrap();
2245
2246        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2247
2248        assert_eq!(normalized.schema(), schema);
2249        assert_eq!(normalized, batch);
2250    }
2251
2252    #[rstest]
2253    fn normalize_legacy_depth_flat_columns_builds_structured_sides() {
2254        let mut fields = Vec::new();
2255        let mut columns = Vec::new();
2256
2257        for side in ["bid", "ask"] {
2258            for level in 0..DEPTH10_LEN {
2259                for (name, value) in [("price", 11_i128), ("size", 22_i128)] {
2260                    fields.push(Field::new(
2261                        format!("{side}_{name}_{level}"),
2262                        DataType::Decimal128(38, 16),
2263                        true,
2264                    ));
2265                    let value = (level == 0).then_some(value);
2266                    columns.push(Arc::new(
2267                        Decimal128Array::from(vec![value])
2268                            .with_precision_and_scale(38, 16)
2269                            .unwrap(),
2270                    ) as ArrayRef);
2271                }
2272                fields.push(Field::new(
2273                    format!("{side}_count_{level}"),
2274                    DataType::UInt32,
2275                    false,
2276                ));
2277                columns.push(Arc::new(UInt32Array::from(vec![33])) as ArrayRef);
2278                fields.push(Field::new(
2279                    format!("{side}_order_id_{level}"),
2280                    DataType::UInt64,
2281                    false,
2282                ));
2283                columns.push(Arc::new(UInt64Array::from(vec![44])) as ArrayRef);
2284            }
2285        }
2286        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2287
2288        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2289
2290        assert_normalized_depth(&normalized, 1, 11, 22, 33, 44);
2291    }
2292
2293    #[rstest]
2294    fn normalize_legacy_depth_fixed_lists_builds_structured_sides() {
2295        let decimal_values = |value| {
2296            Arc::new(
2297                Decimal128Array::from(
2298                    (0..DEPTH10_LEN)
2299                        .map(|level| (level == 0).then_some(value))
2300                        .collect::<Vec<_>>(),
2301                )
2302                .with_precision_and_scale(38, 16)
2303                .unwrap(),
2304            ) as ArrayRef
2305        };
2306        let counts = Arc::new(UInt32Array::from(vec![33; DEPTH10_LEN])) as ArrayRef;
2307        let order_ids = Arc::new(UInt64Array::from(vec![44; DEPTH10_LEN])) as ArrayRef;
2308        let mut fields = Vec::new();
2309        let mut columns = Vec::new();
2310
2311        for side in ["bid", "ask"] {
2312            for (name, column) in [
2313                ("price", depth_list_array(decimal_values(11), true)),
2314                ("size", depth_list_array(decimal_values(22), true)),
2315                ("count", depth_list_array(counts.clone(), false)),
2316                ("order_id", depth_list_array(order_ids.clone(), false)),
2317            ] {
2318                fields.push(Field::new(
2319                    format!("{side}_{name}"),
2320                    column.data_type().clone(),
2321                    false,
2322                ));
2323                columns.push(column);
2324            }
2325        }
2326        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2327
2328        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2329
2330        assert_normalized_depth(&normalized, 1, 11, 22, 33, 44);
2331    }
2332
2333    #[rstest]
2334    fn normalize_legacy_depth_missing_counts_and_order_ids_uses_list_width() {
2335        const WIDTH: i32 = 3;
2336        let decimal_values = |value| {
2337            Arc::new(
2338                Decimal128Array::from(vec![value; WIDTH as usize])
2339                    .with_precision_and_scale(38, 16)
2340                    .unwrap(),
2341            ) as ArrayRef
2342        };
2343        let list = |values: ArrayRef| {
2344            Arc::new(FixedSizeListArray::new(
2345                Arc::new(Field::new("item", values.data_type().clone(), false)),
2346                WIDTH,
2347                values,
2348                None,
2349            )) as ArrayRef
2350        };
2351        let mut fields = Vec::new();
2352        let mut columns = Vec::new();
2353
2354        for side in ["bid", "ask"] {
2355            for (name, column) in [
2356                ("price", list(decimal_values(11))),
2357                ("size", list(decimal_values(22))),
2358            ] {
2359                fields.push(Field::new(
2360                    format!("{side}_{name}"),
2361                    column.data_type().clone(),
2362                    false,
2363                ));
2364                columns.push(column);
2365            }
2366        }
2367        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2368
2369        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2370
2371        assert_normalized_depth(&normalized, WIDTH as usize, 11, 22, 0, 0);
2372    }
2373
2374    #[rstest]
2375    #[case::with_order_ids(true, 44)]
2376    #[case::without_order_ids(false, 0)]
2377    fn normalize_legacy_depth_fixed_binary_lists_matches_schema(
2378        #[case] include_order_ids: bool,
2379        #[case] expected_order_id: u64,
2380    ) {
2381        let fixed_values = |value: [u8; 8]| {
2382            let values = (0..DEPTH10_LEN)
2383                .map(|level| (level == 0).then_some(value))
2384                .collect::<Vec<_>>();
2385            Arc::new(
2386                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2387                    values
2388                        .iter()
2389                        .map(Option::as_ref)
2390                        .map(|value| value.map(<[u8; 8]>::as_slice)),
2391                    8,
2392                )
2393                .unwrap(),
2394            ) as ArrayRef
2395        };
2396        let counts = Arc::new(UInt32Array::from(vec![33; DEPTH10_LEN])) as ArrayRef;
2397        let order_ids = Arc::new(UInt64Array::from(vec![44; DEPTH10_LEN])) as ArrayRef;
2398        let mut fields = Vec::new();
2399        let mut columns = Vec::new();
2400
2401        for side in ["bid", "ask"] {
2402            let mut side_columns = vec![
2403                (
2404                    "price",
2405                    depth_list_array(fixed_values(11_i64.to_le_bytes()), true),
2406                ),
2407                (
2408                    "size",
2409                    depth_list_array(fixed_values(22_u64.to_le_bytes()), true),
2410                ),
2411                ("count", depth_list_array(counts.clone(), false)),
2412            ];
2413
2414            if include_order_ids {
2415                side_columns.push(("order_id", depth_list_array(order_ids.clone(), false)));
2416            }
2417
2418            for (name, column) in side_columns {
2419                fields.push(Field::new(
2420                    format!("{side}_{name}"),
2421                    column.data_type().clone(),
2422                    false,
2423                ));
2424                columns.push(column);
2425            }
2426        }
2427        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2428
2429        assert!(is_nautilus_legacy_schema(batch.schema_ref()));
2430        let normalized_schema = normalize_legacy_parquet_schema(batch.schema_ref());
2431        let normalized_batch = normalize_legacy_parquet_columns(&batch).unwrap();
2432
2433        assert_eq!(
2434            normalized_schema,
2435            normalized_batch.schema().as_ref().clone()
2436        );
2437        assert_normalized_depth(
2438            &normalized_batch,
2439            1,
2440            110_000_000,
2441            220_000_000,
2442            33,
2443            expected_order_id,
2444        );
2445    }
2446
2447    #[rstest]
2448    fn normalize_legacy_depth_flat_fixed_columns_preserves_order_ids() {
2449        let fixed_price = || {
2450            let bytes = 11_i64.to_le_bytes();
2451            Arc::new(
2452                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2453                    [Some(bytes.as_slice())].into_iter(),
2454                    8,
2455                )
2456                .unwrap(),
2457            ) as ArrayRef
2458        };
2459        let fixed_size = || {
2460            let bytes = 22_u64.to_le_bytes();
2461            Arc::new(
2462                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2463                    [Some(bytes.as_slice())].into_iter(),
2464                    8,
2465                )
2466                .unwrap(),
2467            ) as ArrayRef
2468        };
2469        let mut fields = Vec::new();
2470        let mut columns = Vec::new();
2471
2472        for side in ["bid", "ask"] {
2473            for level in 0..DEPTH10_LEN {
2474                fields.push(Field::new(
2475                    format!("{side}_price_{level}"),
2476                    DataType::FixedSizeBinary(8),
2477                    false,
2478                ));
2479                columns.push(fixed_price());
2480                fields.push(Field::new(
2481                    format!("{side}_size_{level}"),
2482                    DataType::FixedSizeBinary(8),
2483                    false,
2484                ));
2485                columns.push(fixed_size());
2486                fields.push(Field::new(
2487                    format!("{side}_count_{level}"),
2488                    DataType::UInt32,
2489                    false,
2490                ));
2491                columns.push(Arc::new(UInt32Array::from(vec![33])) as ArrayRef);
2492                fields.push(Field::new(
2493                    format!("{side}_order_id_{level}"),
2494                    DataType::UInt64,
2495                    false,
2496                ));
2497                columns.push(Arc::new(UInt64Array::from(vec![44])) as ArrayRef);
2498            }
2499        }
2500
2501        for (field, column) in [
2502            (
2503                Field::new("flags", DataType::UInt8, false),
2504                Arc::new(UInt8Array::from(vec![0])) as ArrayRef,
2505            ),
2506            (
2507                Field::new("sequence", DataType::UInt64, false),
2508                Arc::new(UInt64Array::from(vec![1])) as ArrayRef,
2509            ),
2510            (
2511                Field::new("ts_event", DataType::UInt64, false),
2512                Arc::new(UInt64Array::from(vec![2])) as ArrayRef,
2513            ),
2514            (
2515                Field::new("ts_init", DataType::UInt64, false),
2516                Arc::new(UInt64Array::from(vec![3])) as ArrayRef,
2517            ),
2518        ] {
2519            fields.push(field);
2520            columns.push(column);
2521        }
2522        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2523        assert!(is_nautilus_legacy_schema(batch.schema_ref()));
2524
2525        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2526
2527        for side in ["bids", "asks"] {
2528            let list = normalized
2529                .column_by_name(side)
2530                .unwrap()
2531                .as_any()
2532                .downcast_ref::<ListArray>()
2533                .unwrap();
2534            let levels = list.value(0);
2535            let levels = levels.as_any().downcast_ref::<StructArray>().unwrap();
2536            let order_ids = levels
2537                .column_by_name("order_id")
2538                .unwrap()
2539                .as_any()
2540                .downcast_ref::<UInt64Array>()
2541                .unwrap();
2542            assert_eq!(order_ids.values(), &[44; DEPTH10_LEN]);
2543        }
2544    }
2545
2546    #[rstest]
2547    fn normalize_legacy_depth_fixture_matches_open_shape() {
2548        let precision_dir = if cfg!(feature = "high-precision") {
2549            "128-bit"
2550        } else {
2551            "64-bit"
2552        };
2553        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2554            .join("../../test_data/nautilus/legacy")
2555            .join(precision_dir)
2556            .join("depths.parquet");
2557        let file = std::fs::File::open(path).unwrap();
2558        let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
2559        let metadata = builder
2560            .metadata()
2561            .file_metadata()
2562            .key_value_metadata()
2563            .unwrap();
2564
2565        for (key, value) in [
2566            ("instrument_id", "AAPL.XNAS"),
2567            ("price_precision", "4"),
2568            ("size_precision", "1"),
2569        ] {
2570            assert_eq!(
2571                metadata
2572                    .iter()
2573                    .find(|entry| entry.key == key)
2574                    .and_then(|entry| entry.value.as_deref()),
2575                Some(value),
2576            );
2577        }
2578        let batch = builder.build().unwrap().next().unwrap().unwrap();
2579
2580        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2581
2582        assert_normalized_depth(
2583            &normalized,
2584            DEPTH10_LEN,
2585            12_345_000_000_000_000,
2586            25_000_000_000_000_000,
2587            3,
2588            0,
2589        );
2590        assert_eq!(normalized.num_columns(), 6);
2591        assert_eq!(
2592            normalized
2593                .column_by_name("flags")
2594                .unwrap()
2595                .as_any()
2596                .downcast_ref::<UInt8Array>()
2597                .unwrap()
2598                .value(0),
2599            32
2600        );
2601        assert_eq!(
2602            normalized
2603                .column_by_name("sequence")
2604                .unwrap()
2605                .as_any()
2606                .downcast_ref::<UInt64Array>()
2607                .unwrap()
2608                .value(0),
2609            7
2610        );
2611
2612        for name in ["ts_event", "ts_init"] {
2613            assert_eq!(
2614                normalized
2615                    .schema()
2616                    .field_with_name(name)
2617                    .unwrap()
2618                    .data_type(),
2619                &DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, Some("UTC".into())),
2620            );
2621        }
2622    }
2623
2624    #[rstest]
2625    #[case("quotes.parquet", "bid_price", None)]
2626    #[case("trades.parquet", "price", Some("aggressor_side"))]
2627    #[case("bars.parquet", "open", None)]
2628    #[case("deltas.parquet", "price", Some("action"))]
2629    fn legacy_market_fixture_matches_open_types(
2630        #[case] file_name: &str,
2631        #[case] fixed_field: &str,
2632        #[case] enum_field: Option<&str>,
2633    ) {
2634        let precision_dir = if cfg!(feature = "high-precision") {
2635            "128-bit"
2636        } else {
2637            "64-bit"
2638        };
2639        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2640            .join("../../test_data/nautilus/legacy")
2641            .join(precision_dir)
2642            .join(file_name);
2643        let file = std::fs::File::open(path).unwrap();
2644        let batch = ParquetRecordBatchReaderBuilder::try_new(file)
2645            .unwrap()
2646            .build()
2647            .unwrap()
2648            .next()
2649            .unwrap()
2650            .unwrap();
2651
2652        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2653
2654        assert_eq!(
2655            normalized
2656                .schema()
2657                .field_with_name(fixed_field)
2658                .unwrap()
2659                .data_type(),
2660            &DataType::Decimal128(38, 16),
2661        );
2662        assert_eq!(
2663            normalized
2664                .schema()
2665                .field_with_name("ts_init")
2666                .unwrap()
2667                .data_type(),
2668            &DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, Some("UTC".into())),
2669        );
2670
2671        if let Some(enum_field) = enum_field {
2672            assert!(matches!(
2673                normalized
2674                    .schema()
2675                    .field_with_name(enum_field)
2676                    .unwrap()
2677                    .data_type(),
2678                DataType::Dictionary(_, value) if value.as_ref() == &DataType::Utf8
2679            ));
2680        }
2681    }
2682
2683    #[rstest]
2684    #[case("depths.parquet")]
2685    #[case("quotes.parquet")]
2686    #[case("trades.parquet")]
2687    #[case("bars.parquet")]
2688    #[case("deltas.parquet")]
2689    #[case("dictionary-trade")]
2690    fn legacy_fixture_schema_normalization_matches_batch(#[case] file_name: &str) {
2691        let (schema, batch) = if file_name == "dictionary-trade" {
2692            let dictionary = |value: &str| {
2693                let mut builder = StringDictionaryBuilder::<Int8Type>::new();
2694                builder.append(value).unwrap();
2695                Arc::new(builder.finish()) as ArrayRef
2696            };
2697            let price = 11_i64.to_le_bytes();
2698            let size = 22_u64.to_le_bytes();
2699            let trade_ids = dictionary("trade-1");
2700            let identifiers = dictionary("AAPL.XNAS");
2701            let schema = Arc::new(Schema::new_with_metadata(
2702                vec![
2703                    Field::new("price", DataType::FixedSizeBinary(8), false),
2704                    Field::new("size", DataType::FixedSizeBinary(8), false),
2705                    Field::new("aggressor_side", DataType::UInt8, false),
2706                    Field::new("trade_id", trade_ids.data_type().clone(), false),
2707                    Field::new("ts_event", DataType::UInt64, false),
2708                    Field::new("ts_init", DataType::UInt64, false),
2709                    Field::new(KEY_IDENTIFIER, identifiers.data_type().clone(), false),
2710                ],
2711                HashMap::from([("type".to_string(), "TradeTick".to_string())]),
2712            ));
2713            let batch = RecordBatch::try_new(
2714                Arc::clone(&schema),
2715                vec![
2716                    Arc::new(
2717                        FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2718                            [Some(price.as_slice())].into_iter(),
2719                            8,
2720                        )
2721                        .unwrap(),
2722                    ),
2723                    Arc::new(
2724                        FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2725                            [Some(size.as_slice())].into_iter(),
2726                            8,
2727                        )
2728                        .unwrap(),
2729                    ),
2730                    Arc::new(UInt8Array::from(vec![1])),
2731                    trade_ids,
2732                    Arc::new(UInt64Array::from(vec![1])),
2733                    Arc::new(UInt64Array::from(vec![2])),
2734                    identifiers,
2735                ],
2736            )
2737            .unwrap();
2738            (schema, batch)
2739        } else {
2740            let precision_dir = if cfg!(feature = "high-precision") {
2741                "128-bit"
2742            } else {
2743                "64-bit"
2744            };
2745            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2746                .join("../../test_data/nautilus/legacy")
2747                .join(precision_dir)
2748                .join(file_name);
2749            let file = std::fs::File::open(path).unwrap();
2750            let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
2751            let schema = builder.schema().clone();
2752            let batch = builder.build().unwrap().next().unwrap().unwrap();
2753            let batch =
2754                RecordBatch::try_new(Arc::clone(&schema), batch.columns().to_vec()).unwrap();
2755            (schema, batch)
2756        };
2757        let normalized_schema = normalize_legacy_parquet_schema(schema.as_ref());
2758        let normalized_batch = normalize_legacy_parquet_columns(&batch).unwrap();
2759
2760        assert_eq!(
2761            normalized_schema,
2762            normalized_batch.schema().as_ref().clone()
2763        );
2764
2765        if file_name == "dictionary-trade" {
2766            assert_eq!(
2767                normalized_batch
2768                    .schema()
2769                    .field_with_name("trade_id")
2770                    .unwrap()
2771                    .data_type(),
2772                &DataType::Utf8,
2773            );
2774        }
2775    }
2776
2777    fn assert_normalized_depth(
2778        batch: &RecordBatch,
2779        level_count: usize,
2780        price: i128,
2781        size: i128,
2782        count: u32,
2783        order_id: u64,
2784    ) {
2785        let schema = batch.schema();
2786        assert_eq!(schema.field(0).name(), "bids");
2787        assert_eq!(schema.field(1).name(), "asks");
2788
2789        for side in ["bids", "asks"] {
2790            let list = batch
2791                .column_by_name(side)
2792                .unwrap()
2793                .as_any()
2794                .downcast_ref::<ListArray>()
2795                .unwrap();
2796            let levels = list.value(0);
2797            let levels = levels.as_any().downcast_ref::<StructArray>().unwrap();
2798            let prices = levels
2799                .column_by_name("price")
2800                .unwrap()
2801                .as_any()
2802                .downcast_ref::<Decimal128Array>()
2803                .unwrap();
2804            let sizes = levels
2805                .column_by_name("size")
2806                .unwrap()
2807                .as_any()
2808                .downcast_ref::<Decimal128Array>()
2809                .unwrap();
2810            let counts = levels
2811                .column_by_name("count")
2812                .unwrap()
2813                .as_any()
2814                .downcast_ref::<UInt32Array>()
2815                .unwrap();
2816            let order_ids = levels
2817                .column_by_name("order_id")
2818                .unwrap()
2819                .as_any()
2820                .downcast_ref::<UInt64Array>()
2821                .unwrap();
2822
2823            assert_eq!(levels.len(), level_count);
2824            assert_eq!(prices.value(0), price);
2825            assert_eq!(sizes.value(0), size);
2826            assert_eq!(counts.value(0), count);
2827            assert_eq!(order_ids.value(0), order_id);
2828        }
2829    }
2830
2831    #[tokio::test]
2832    async fn default_writer_sets_zstd_sorting_and_identifier_bloom_filter() {
2833        let directory = tempfile::tempdir().unwrap();
2834        let path = directory.path().join("layout.parquet");
2835        let object_store = Arc::new(
2836            object_store::local::LocalFileSystem::new_with_prefix(directory.path()).unwrap(),
2837        );
2838        let object_path = ObjectPath::from("layout.parquet");
2839        let schema = Arc::new(Schema::new(vec![
2840            Field::new("identifier", DataType::Utf8, false),
2841            Field::new("ts_init", DataType::UInt64, false),
2842        ]));
2843        let batch = RecordBatch::try_new(
2844            schema,
2845            vec![
2846                Arc::new(StringArray::from(vec!["AUD/USD.SIM", "AUD/USD.SIM"])) as ArrayRef,
2847                Arc::new(UInt64Array::from(vec![1_u64, 2])) as ArrayRef,
2848            ],
2849        )
2850        .unwrap();
2851
2852        write_batches_to_object_store(&[batch], object_store, &object_path, None, None, None)
2853            .await
2854            .unwrap();
2855
2856        let read_options = ReadOptionsBuilder::new()
2857            .with_reader_properties(
2858                ReaderProperties::builder()
2859                    .set_read_bloom_filter(true)
2860                    .build(),
2861            )
2862            .build();
2863        let reader = SerializedFileReader::new_with_options(
2864            std::fs::File::open(path).unwrap(),
2865            read_options,
2866        )
2867        .unwrap();
2868        let row_group = reader.metadata().row_group(0);
2869        let sorting = row_group.sorting_columns().unwrap();
2870
2871        assert_eq!(crate::backend::parquet::DEFAULT_ROW_GROUP_SIZE, 131_072);
2872        assert_eq!(
2873            sorting,
2874            &vec![
2875                SortingColumn {
2876                    column_idx: 1,
2877                    descending: false,
2878                    nulls_first: false,
2879                },
2880                SortingColumn {
2881                    column_idx: 0,
2882                    descending: false,
2883                    nulls_first: false,
2884                },
2885            ],
2886        );
2887        assert!(
2888            row_group
2889                .columns()
2890                .iter()
2891                .all(|column| column.compression() == Compression::ZSTD(ZstdLevel::default())),
2892        );
2893        assert!(
2894            reader
2895                .get_row_group(0)
2896                .unwrap()
2897                .get_column_bloom_filter(0)
2898                .is_some(),
2899        );
2900    }
2901
2902    #[rstest]
2903    fn test_create_object_store_from_path_local() {
2904        // Create a temporary directory for testing
2905        let temp_dir = std::env::temp_dir().join("nautilus_test");
2906        std::fs::create_dir_all(&temp_dir).unwrap();
2907
2908        let result = create_object_store_from_path(temp_dir.to_str().unwrap(), None);
2909        if let Err(e) = &result {
2910            println!("Error: {e:?}");
2911        }
2912        assert!(result.is_ok());
2913        let (_, base_path, uri) = result.unwrap();
2914        assert_eq!(base_path, "");
2915        // The URI should be normalized to file:// format
2916        assert_eq!(uri, format!("file://{}", temp_dir.to_str().unwrap()));
2917
2918        // Clean up
2919        std::fs::remove_dir_all(&temp_dir).ok();
2920    }
2921
2922    #[rstest]
2923    #[cfg(feature = "cloud")]
2924    fn test_create_object_store_from_path_s3() {
2925        let mut options = AHashMap::new();
2926        options.insert(
2927            "endpoint_url".to_string(),
2928            "https://test.endpoint.com".to_string(),
2929        );
2930        options.insert("region".to_string(), "us-west-2".to_string());
2931        options.insert("access_key_id".to_string(), "test_key".to_string());
2932        options.insert("secret_access_key".to_string(), "test_secret".to_string());
2933
2934        let result = create_object_store_from_path("s3://test-bucket/path", Some(options));
2935        assert!(result.is_ok());
2936        let (_, base_path, uri) = result.unwrap();
2937        assert_eq!(base_path, "path");
2938        assert_eq!(uri, "s3://test-bucket/path");
2939    }
2940
2941    #[rstest]
2942    #[cfg(feature = "cloud")]
2943    fn test_create_object_store_from_path_azure() {
2944        let mut options = AHashMap::new();
2945        options.insert("account_name".to_string(), "testaccount".to_string());
2946        // Use a valid base64 encoded key for testing
2947        options.insert("account_key".to_string(), "dGVzdGtleQ==".to_string()); // "testkey" in base64
2948
2949        let result = create_object_store_from_path("az://container/path", Some(options));
2950        if let Err(e) = &result {
2951            println!("Azure Error: {e:?}");
2952        }
2953        assert!(result.is_ok());
2954        let (_, base_path, uri) = result.unwrap();
2955        assert_eq!(base_path, "path");
2956        assert_eq!(uri, "az://container/path");
2957    }
2958
2959    #[rstest]
2960    #[cfg(feature = "cloud")]
2961    fn test_create_object_store_from_path_gcs() {
2962        // Test GCS without service account (will use default credentials or fail gracefully)
2963        let mut options = AHashMap::new();
2964        options.insert("project_id".to_string(), "test-project".to_string());
2965
2966        let result = create_object_store_from_path("gs://test-bucket/path", Some(options));
2967        // GCS might fail due to missing credentials, but we're testing the path parsing
2968        // The function should at least parse the URI correctly before failing on auth
2969        match result {
2970            Ok((_, base_path, uri)) => {
2971                assert_eq!(base_path, "path");
2972                assert_eq!(uri, "gs://test-bucket/path");
2973            }
2974            Err(e) => {
2975                // Expected to fail due to missing credentials, but should contain bucket info
2976                let error_msg = format!("{e:?}");
2977                assert!(error_msg.contains("test-bucket") || error_msg.contains("credential"));
2978            }
2979        }
2980    }
2981
2982    #[rstest]
2983    #[cfg(feature = "cloud")]
2984    fn test_create_object_store_from_path_empty_options() {
2985        let result = create_object_store_from_path("s3://test-bucket/path", None);
2986        assert!(result.is_ok());
2987        let (_, base_path, uri) = result.unwrap();
2988        assert_eq!(base_path, "path");
2989        assert_eq!(uri, "s3://test-bucket/path");
2990    }
2991
2992    #[rstest]
2993    #[cfg(feature = "cloud")]
2994    fn test_remote_store_root_url_preserves_authority() {
2995        let https_root = remote_store_root_url("https://example.com:9000/base/path").unwrap();
2996        assert_eq!(
2997            https_root.as_str().trim_end_matches('/'),
2998            "https://example.com:9000"
2999        );
3000
3001        let abfs_root =
3002            remote_store_root_url("abfs://container@account.dfs.core.windows.net/base/path")
3003                .unwrap();
3004        assert_eq!(
3005            abfs_root.as_str().trim_end_matches('/'),
3006            "abfs://container@account.dfs.core.windows.net"
3007        );
3008
3009        let full_uri = remote_full_uri(
3010            "https://example.com:9000/base/path",
3011            "base/path/data/%5E/file.parquet",
3012        )
3013        .unwrap();
3014        assert_eq!(
3015            full_uri,
3016            "https://example.com:9000/base/path/data/%5E/file.parquet"
3017        );
3018
3019        let location = create_object_store_location_from_path("s3://test-bucket/path", None)
3020            .expect("S3 location should be created");
3021        assert_eq!(location.base_path, "path");
3022        assert_eq!(
3023            remote_store_root_url(&location.original_uri)
3024                .expect("S3 should be remote")
3025                .as_str()
3026                .trim_end_matches('/'),
3027            "s3://test-bucket"
3028        );
3029    }
3030}
3031
3032#[cfg(test)]
3033mod migration_tests {
3034    use std::{collections::HashMap, sync::Arc};
3035
3036    use arrow::{
3037        array::{
3038            ArrayRef, FixedSizeBinaryArray, ListArray, StringArray, StringDictionaryBuilder,
3039            TimestampNanosecondArray, UInt8Array, UInt32Array, UInt64Array,
3040        },
3041        datatypes::{DataType, Field, Int8Type, Schema, TimeUnit},
3042    };
3043    use nautilus_model::{
3044        data::QuoteTick,
3045        identifiers::InstrumentId,
3046        types::{Price, Quantity},
3047    };
3048    use nautilus_serialization::arrow::{
3049        DecodeFromRecordBatch, EncodeToRecordBatch, json_string_field,
3050    };
3051    use parquet::file::{properties::ReaderProperties, serialized_reader::ReadOptionsBuilder};
3052    use rstest::rstest;
3053
3054    use super::*;
3055    use crate::backend::parquet::DEFAULT_ROW_GROUP_SIZE;
3056
3057    #[rstest]
3058    #[case::utc(Some("UTC"))]
3059    #[case::canonical(None)]
3060    fn v2_timestamp_normalization_matches_preflight_and_preserves_values(
3061        #[case] timezone: Option<&str>,
3062    ) {
3063        let first = QuoteTick {
3064            instrument_id: InstrumentId::from("AAPL.XNAS"),
3065            bid_price: Price::from("123.45"),
3066            ask_price: Price::from("123.67"),
3067            bid_size: Quantity::from(17),
3068            ask_size: Quantity::from(29),
3069            ts_event: 1_788_652_800_123_456_789_u64.into(),
3070            ts_init: 1_788_652_800_123_456_799_u64.into(),
3071        };
3072        let values = vec![
3073            first,
3074            QuoteTick {
3075                ts_event: 1_788_652_800_123_456_801_u64.into(),
3076                ts_init: 1_788_652_800_123_456_899_u64.into(),
3077                ..first
3078            },
3079        ];
3080        let metadata = QuoteTick::get_metadata(&first.instrument_id, 2, 0);
3081        let expected = QuoteTick::encode_batch(&metadata, &values).unwrap();
3082        let source_type = DataType::Timestamp(TimeUnit::Nanosecond, timezone.map(Into::into));
3083        let fields = expected
3084            .schema()
3085            .fields()
3086            .iter()
3087            .map(|field| {
3088                let field = field.as_ref().clone();
3089                if matches!(field.data_type(), DataType::Timestamp(_, _)) {
3090                    field.with_data_type(source_type.clone())
3091                } else {
3092                    field
3093                }
3094            })
3095            .collect::<Vec<_>>();
3096        let columns = expected
3097            .columns()
3098            .iter()
3099            .map(|column| {
3100                if let Some(timestamps) = column.as_any().downcast_ref::<TimestampNanosecondArray>()
3101                {
3102                    Arc::new(
3103                        timestamps
3104                            .clone()
3105                            .with_timezone_opt(timezone.map(Arc::<str>::from)),
3106                    ) as ArrayRef
3107                } else {
3108                    column.clone()
3109                }
3110            })
3111            .collect::<Vec<_>>();
3112        let source = RecordBatch::try_new(
3113            Arc::new(Schema::new_with_metadata(fields, metadata.clone())),
3114            columns,
3115        )
3116        .unwrap();
3117        let preflight = normalize_legacy_parquet_schema(source.schema_ref());
3118        let normalized = normalize_legacy_parquet_columns(&source).unwrap();
3119        assert_eq!(&preflight, expected.schema_ref().as_ref());
3120        assert_eq!(normalized, expected);
3121        assert_eq!(
3122            QuoteTick::decode_batch(&metadata, normalized).unwrap(),
3123            values
3124        );
3125    }
3126
3127    #[rstest]
3128    fn timestamp_normalization_preserves_custom_nulls_and_unrelated_numeric_fields() {
3129        let schema = Arc::new(Schema::new_with_metadata(
3130            vec![
3131                Field::new(
3132                    "ts_event",
3133                    DataType::Timestamp(TimeUnit::Nanosecond, None),
3134                    true,
3135                ),
3136                Field::new("ts_count", DataType::UInt64, false),
3137            ],
3138            HashMap::from([("type_name".to_string(), "TimestampSample".to_string())]),
3139        ));
3140        let timestamps =
3141            TimestampNanosecondArray::from(vec![Some(1_788_652_800_123_456_789), None]);
3142        let source = RecordBatch::try_new(
3143            schema,
3144            vec![
3145                Arc::new(timestamps),
3146                Arc::new(UInt64Array::from(vec![17, 29])),
3147            ],
3148        )
3149        .unwrap();
3150        let normalized = normalize_legacy_parquet_columns(&source).unwrap();
3151        assert_eq!(
3152            &normalize_legacy_parquet_schema(source.schema_ref()),
3153            normalized.schema_ref().as_ref()
3154        );
3155        assert_eq!(
3156            normalized.schema().field(0).data_type(),
3157            &DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
3158        );
3159        assert_eq!(
3160            normalized
3161                .column(0)
3162                .as_any()
3163                .downcast_ref::<TimestampNanosecondArray>()
3164                .unwrap()
3165                .iter()
3166                .collect::<Vec<_>>(),
3167            vec![Some(1_788_652_800_123_456_789), None]
3168        );
3169        assert_eq!(normalized.column(1), source.column(1));
3170    }
3171
3172    fn consolidation_depth_schema(
3173        price_precision: &str,
3174        size_precision: &str,
3175        instrument_id: &str,
3176    ) -> Arc<Schema> {
3177        Arc::new(Schema::new_with_metadata(
3178            vec![Field::new("bids", DataType::Utf8, false)],
3179            HashMap::from([
3180                (KEY_PRICE_PRECISION.to_string(), price_precision.to_string()),
3181                (KEY_SIZE_PRECISION.to_string(), size_precision.to_string()),
3182                (KEY_IDENTIFIER.to_string(), instrument_id.to_string()),
3183            ]),
3184        ))
3185    }
3186
3187    #[rstest]
3188    fn consolidation_schema_prefers_populated_depth_precision_in_either_order() {
3189        let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
3190        let populated = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
3191        let fallback_path = ObjectPath::from("empty.parquet");
3192        let populated_path = ObjectPath::from("populated.parquet");
3193
3194        let fallback_first =
3195            reconcile_consolidation_schema(&fallback, &fallback_path, &populated, &populated_path)
3196                .unwrap();
3197        let populated_first =
3198            reconcile_consolidation_schema(&populated, &populated_path, &fallback, &fallback_path)
3199                .unwrap();
3200
3201        assert_eq!(
3202            fallback_first.schema_source,
3203            ConsolidationSchemaSource::Candidate,
3204        );
3205        assert_eq!(
3206            populated_first.schema_source,
3207            ConsolidationSchemaSource::Current,
3208        );
3209
3210        for schema in [fallback_first.schema, populated_first.schema] {
3211            assert_eq!(schema.metadata()[KEY_PRICE_PRECISION], "2");
3212            assert_eq!(schema.metadata()[KEY_SIZE_PRECISION], "3");
3213        }
3214    }
3215
3216    #[rstest]
3217    fn consolidation_schema_rejects_other_metadata_mismatches() {
3218        let current = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
3219        let candidate = consolidation_depth_schema("2", "3", "BTCUSDT.BINANCE");
3220        let current_path = ObjectPath::from("eth.parquet");
3221        let candidate_path = ObjectPath::from("btc.parquet");
3222
3223        let error =
3224            reconcile_consolidation_schema(&current, &current_path, &candidate, &candidate_path)
3225                .unwrap_err();
3226
3227        assert!(error.to_string().contains("eth.parquet and btc.parquet"));
3228        assert!(error.to_string().contains("ETHUSDT.BINANCE"));
3229        assert!(error.to_string().contains("BTCUSDT.BINANCE"));
3230    }
3231
3232    #[rstest]
3233    fn consolidation_schema_rejects_two_populated_precisions() {
3234        let current = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
3235        let candidate = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
3236        let current_path = ObjectPath::from("precision-2.parquet");
3237        let candidate_path = ObjectPath::from("precision-4.parquet");
3238
3239        let error =
3240            reconcile_consolidation_schema(&current, &current_path, &candidate, &candidate_path)
3241                .unwrap_err();
3242
3243        assert!(error.to_string().contains("precision-2.parquet"));
3244        assert!(error.to_string().contains("precision-4.parquet"));
3245    }
3246
3247    #[rstest]
3248    fn consolidation_schema_keeps_fallback_for_all_empty_files() {
3249        let first = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
3250        let second = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
3251
3252        let reconciled = reconcile_consolidation_schema(
3253            &first,
3254            &ObjectPath::from("first-empty.parquet"),
3255            &second,
3256            &ObjectPath::from("second-empty.parquet"),
3257        )
3258        .unwrap();
3259
3260        assert_eq!(reconciled.schema.metadata()[KEY_PRICE_PRECISION], "0");
3261        assert_eq!(reconciled.schema.metadata()[KEY_SIZE_PRECISION], "0");
3262    }
3263
3264    #[rstest]
3265    fn consolidation_schema_rejects_missing_precision_against_fallback() {
3266        let missing = Arc::new(Schema::new_with_metadata(
3267            vec![Field::new("bids", DataType::Utf8, false)],
3268            HashMap::from([(KEY_IDENTIFIER.to_string(), "ETHUSDT.BINANCE".to_string())]),
3269        ));
3270        let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
3271
3272        let error = reconcile_consolidation_schema(
3273            &missing,
3274            &ObjectPath::from("missing.parquet"),
3275            &fallback,
3276            &ObjectPath::from("fallback.parquet"),
3277        )
3278        .unwrap_err();
3279
3280        assert!(error.to_string().contains("schema metadata differs"));
3281    }
3282
3283    #[rstest]
3284    fn normalize_legacy_info_schema_makes_binary_info_nullable() {
3285        let schema = Schema::new(vec![Field::new("info", DataType::Binary, false)]);
3286
3287        let normalized = normalize_legacy_parquet_schema(&schema);
3288
3289        let info = normalized.field_with_name("info").unwrap();
3290        assert_eq!(info.data_type(), &DataType::Utf8);
3291        assert!(info.is_nullable());
3292    }
3293
3294    #[rstest]
3295    fn consolidation_schema_merges_json_field_annotation_in_either_order() {
3296        let bare = Arc::new(Schema::new(vec![Field::new("info", DataType::Utf8, true)]));
3297        let annotated = Arc::new(Schema::new(vec![json_string_field("info", true)]));
3298        let bare_path = ObjectPath::from("bare.parquet");
3299        let annotated_path = ObjectPath::from("annotated.parquet");
3300
3301        let bare_first =
3302            reconcile_consolidation_schema(&bare, &bare_path, &annotated, &annotated_path).unwrap();
3303        let annotated_first =
3304            reconcile_consolidation_schema(&annotated, &annotated_path, &bare, &bare_path).unwrap();
3305
3306        assert_eq!(bare_first.schema, annotated_first.schema);
3307        assert_eq!(
3308            bare_first.schema.field_with_name("info").unwrap(),
3309            &json_string_field("info", true),
3310        );
3311    }
3312
3313    #[rstest]
3314    fn consolidation_field_metadata_conflict_names_the_winning_file() {
3315        let bare = Arc::new(Schema::new(vec![Field::new("info", DataType::Utf8, true)]));
3316        let annotated = Arc::new(Schema::new(vec![json_string_field("info", true)]));
3317        let conflicting = Arc::new(Schema::new(vec![
3318            Field::new("info", DataType::Utf8, true).with_metadata(HashMap::from([(
3319                "ARROW:extension:name".to_string(),
3320                "other.extension".to_string(),
3321            )])),
3322        ]));
3323        let bare_path = ObjectPath::from("bare.parquet");
3324        let annotated_path = ObjectPath::from("annotated.parquet");
3325        let conflicting_path = ObjectPath::from("conflicting.parquet");
3326        let reconciled =
3327            reconcile_consolidation_schema(&bare, &bare_path, &annotated, &annotated_path).unwrap();
3328
3329        assert_eq!(reconciled.candidate_field_metadata.len(), 2);
3330        assert!(
3331            reconciled
3332                .candidate_field_metadata
3333                .contains(&("info".to_string(), "ARROW:extension:name".to_string())),
3334        );
3335        assert!(
3336            reconciled
3337                .candidate_field_metadata
3338                .contains(&("info".to_string(), "ARROW:extension:metadata".to_string())),
3339        );
3340        let field_metadata_sources = reconciled
3341            .candidate_field_metadata
3342            .iter()
3343            .cloned()
3344            .map(|key| (key, annotated_path.clone()))
3345            .collect();
3346        let error = reconcile_consolidation_schema_with_sources(
3347            &reconciled.schema,
3348            &bare_path,
3349            &field_metadata_sources,
3350            &conflicting,
3351            &conflicting_path,
3352        )
3353        .unwrap_err();
3354        assert!(error.to_string().contains("annotated.parquet"));
3355        assert!(error.to_string().contains("conflicting.parquet"));
3356        assert!(error.to_string().contains("field 'info' metadata differs"));
3357    }
3358
3359    #[rstest]
3360    fn consolidation_conflict_names_the_last_winning_schema() {
3361        let fallback = consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE");
3362        let precision_2 = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
3363        let precision_4 = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
3364        let fallback_path = ObjectPath::from("fallback.parquet");
3365        let precision_2_path = ObjectPath::from("precision-2.parquet");
3366        let precision_4_path = ObjectPath::from("precision-4.parquet");
3367        let reconciled = reconcile_consolidation_schema(
3368            &fallback,
3369            &fallback_path,
3370            &precision_2,
3371            &precision_2_path,
3372        )
3373        .unwrap();
3374
3375        let error = reconcile_consolidation_schema(
3376            &reconciled.schema,
3377            &precision_2_path,
3378            &precision_4,
3379            &precision_4_path,
3380        )
3381        .unwrap_err();
3382
3383        assert!(error.to_string().contains("precision-2.parquet"));
3384        assert!(error.to_string().contains("precision-4.parquet"));
3385    }
3386
3387    #[rstest]
3388    fn consolidation_field_metadata_does_not_replace_precision_source() {
3389        let precision_2 = consolidation_depth_schema("2", "3", "ETHUSDT.BINANCE");
3390        let fallback = Arc::new(Schema::new_with_metadata(
3391            vec![json_string_field("bids", false)],
3392            consolidation_depth_schema("0", "0", "ETHUSDT.BINANCE")
3393                .metadata()
3394                .clone(),
3395        ));
3396        let precision_4 = consolidation_depth_schema("4", "5", "ETHUSDT.BINANCE");
3397        let precision_2_path = ObjectPath::from("precision-2.parquet");
3398        let fallback_path = ObjectPath::from("fallback-annotated.parquet");
3399        let precision_4_path = ObjectPath::from("precision-4.parquet");
3400        let reconciled = reconcile_consolidation_schema(
3401            &precision_2,
3402            &precision_2_path,
3403            &fallback,
3404            &fallback_path,
3405        )
3406        .unwrap();
3407
3408        assert_eq!(reconciled.schema_source, ConsolidationSchemaSource::Current,);
3409        assert_eq!(reconciled.candidate_field_metadata.len(), 2);
3410        assert!(
3411            reconciled
3412                .candidate_field_metadata
3413                .contains(&("bids".to_string(), "ARROW:extension:name".to_string())),
3414        );
3415        assert!(
3416            reconciled
3417                .candidate_field_metadata
3418                .contains(&("bids".to_string(), "ARROW:extension:metadata".to_string())),
3419        );
3420        let field_metadata_sources = reconciled
3421            .candidate_field_metadata
3422            .iter()
3423            .cloned()
3424            .map(|key| (key, fallback_path.clone()))
3425            .collect();
3426        let error = reconcile_consolidation_schema_with_sources(
3427            &reconciled.schema,
3428            &precision_2_path,
3429            &field_metadata_sources,
3430            &precision_4,
3431            &precision_4_path,
3432        )
3433        .unwrap_err();
3434        let message = error.to_string();
3435
3436        assert!(message.contains("precision-2.parquet"));
3437        assert!(message.contains("precision-4.parquet"));
3438        assert!(!message.contains("fallback-annotated.parquet"));
3439    }
3440
3441    #[rstest]
3442    fn consolidation_field_metadata_tracks_each_origin() {
3443        let bare = Arc::new(Schema::new(vec![
3444            Field::new("info", DataType::Utf8, true),
3445            Field::new("balances", DataType::Utf8, true),
3446        ]));
3447        let info = Arc::new(Schema::new(vec![
3448            json_string_field("info", true),
3449            Field::new("balances", DataType::Utf8, true),
3450        ]));
3451        let balances = Arc::new(Schema::new(vec![
3452            json_string_field("info", true),
3453            json_string_field("balances", true),
3454        ]));
3455        let conflicting = Arc::new(Schema::new(vec![
3456            Field::new("info", DataType::Utf8, true).with_metadata(HashMap::from([(
3457                "ARROW:extension:name".to_string(),
3458                "other.extension".to_string(),
3459            )])),
3460            json_string_field("balances", true),
3461        ]));
3462        let bare_path = ObjectPath::from("bare.parquet");
3463        let info_path = ObjectPath::from("info.parquet");
3464        let balances_path = ObjectPath::from("balances.parquet");
3465        let conflicting_path = ObjectPath::from("conflicting.parquet");
3466        let with_info =
3467            reconcile_consolidation_schema(&bare, &bare_path, &info, &info_path).unwrap();
3468        let mut field_metadata_sources = with_info
3469            .candidate_field_metadata
3470            .iter()
3471            .cloned()
3472            .map(|key| (key, info_path.clone()))
3473            .collect::<HashMap<_, _>>();
3474        let with_balances = reconcile_consolidation_schema_with_sources(
3475            &with_info.schema,
3476            &bare_path,
3477            &field_metadata_sources,
3478            &balances,
3479            &balances_path,
3480        )
3481        .unwrap();
3482
3483        for key in with_balances.candidate_field_metadata {
3484            field_metadata_sources.insert(key, balances_path.clone());
3485        }
3486
3487        let error = reconcile_consolidation_schema_with_sources(
3488            &with_balances.schema,
3489            &bare_path,
3490            &field_metadata_sources,
3491            &conflicting,
3492            &conflicting_path,
3493        )
3494        .unwrap_err();
3495        let message = error.to_string();
3496
3497        assert!(message.contains("info.parquet"));
3498        assert!(message.contains("conflicting.parquet"));
3499        assert!(!message.contains("balances.parquet"));
3500    }
3501
3502    #[rstest]
3503    fn normalize_dictionary_string_columns_casts_string_dictionaries_to_utf8() {
3504        let mut builder = StringDictionaryBuilder::<Int8Type>::new();
3505        builder.append("AUD/USD.SIM").unwrap();
3506        builder.append("EUR/USD.SIM").unwrap();
3507        let dictionary = Arc::new(builder.finish()) as ArrayRef;
3508        let schema = Arc::new(Schema::new(vec![
3509            Field::new("instrument_id", dictionary.data_type().clone(), false),
3510            Field::new("ts_init", DataType::UInt64, false),
3511        ]));
3512        let batch = RecordBatch::try_new(
3513            schema,
3514            vec![
3515                dictionary,
3516                Arc::new(UInt64Array::from(vec![1_u64, 2])) as ArrayRef,
3517            ],
3518        )
3519        .unwrap();
3520
3521        let normalized = normalize_dictionary_string_columns(&batch).unwrap();
3522
3523        assert_eq!(
3524            normalized
3525                .schema()
3526                .field_with_name("instrument_id")
3527                .unwrap()
3528                .data_type(),
3529            &DataType::Utf8,
3530        );
3531        let values = normalized
3532            .column_by_name("instrument_id")
3533            .unwrap()
3534            .as_any()
3535            .downcast_ref::<StringArray>()
3536            .unwrap()
3537            .iter()
3538            .collect::<Vec<_>>();
3539        assert_eq!(values, vec![Some("AUD/USD.SIM"), Some("EUR/USD.SIM")]);
3540    }
3541
3542    #[rstest]
3543    fn normalize_legacy_parquet_columns_preserves_unrecognized_dictionary() {
3544        let mut builder = StringDictionaryBuilder::<Int8Type>::new();
3545        builder.append("alpha").unwrap();
3546        let dictionary = Arc::new(builder.finish()) as ArrayRef;
3547        let schema = Arc::new(Schema::new(vec![Field::new(
3548            "label",
3549            dictionary.data_type().clone(),
3550            false,
3551        )]));
3552        let batch = RecordBatch::try_new(schema, vec![dictionary]).unwrap();
3553
3554        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3555
3556        assert_eq!(normalized, batch);
3557    }
3558
3559    #[rstest]
3560    fn normalize_open_custom_columns_preserves_dictionary_with_type_metadata() {
3561        let mut builder = StringDictionaryBuilder::<Int8Type>::new();
3562        builder.append("alpha").unwrap();
3563        let dictionary = Arc::new(builder.finish()) as ArrayRef;
3564        let decimal = Arc::new(
3565            Decimal128Array::from(vec![Some(123_i128)])
3566                .with_precision_and_scale(38, 16)
3567                .unwrap(),
3568        ) as ArrayRef;
3569        let schema = Arc::new(Schema::new_with_metadata(
3570            vec![
3571                Field::new("label", dictionary.data_type().clone(), false),
3572                Field::new("price", decimal.data_type().clone(), false),
3573                Field::new("ts_recv", DataType::UInt64, false),
3574            ],
3575            HashMap::from([("type_name".to_string(), "CustomData".to_string())]),
3576        ));
3577        let batch = RecordBatch::try_new(
3578            schema,
3579            vec![dictionary, decimal, Arc::new(UInt64Array::from(vec![7]))],
3580        )
3581        .unwrap();
3582
3583        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3584
3585        assert_eq!(normalized, batch);
3586    }
3587
3588    #[rstest]
3589    fn normalize_legacy_parquet_schema_preserves_unrecognized_fixed_binary() {
3590        let schema = Schema::new(vec![Field::new(
3591            "price",
3592            DataType::FixedSizeBinary(8),
3593            false,
3594        )]);
3595
3596        let normalized = normalize_legacy_parquet_schema(&schema);
3597
3598        assert_eq!(normalized, schema);
3599    }
3600
3601    #[rstest]
3602    fn normalize_legacy_parquet_columns_converts_binary_info_null_to_arrow_null() {
3603        let schema = Arc::new(Schema::new(vec![
3604            Field::new("info", DataType::Binary, true),
3605            Field::new("ts_init", DataType::UInt64, false),
3606        ]));
3607        let batch = RecordBatch::try_new(
3608            schema,
3609            vec![
3610                Arc::new(BinaryArray::from_vec(vec![b"null".as_slice()])) as ArrayRef,
3611                Arc::new(UInt64Array::from(vec![1_u64])) as ArrayRef,
3612            ],
3613        )
3614        .unwrap();
3615
3616        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3617        let info = normalized
3618            .column_by_name("info")
3619            .unwrap()
3620            .as_any()
3621            .downcast_ref::<StringArray>()
3622            .unwrap();
3623
3624        assert_eq!(
3625            normalized
3626                .schema()
3627                .field_with_name("info")
3628                .unwrap()
3629                .data_type(),
3630            &DataType::Utf8,
3631        );
3632        assert!(info.is_null(0));
3633    }
3634
3635    #[rstest]
3636    fn normalize_legacy_parquet_columns_preserves_quote_price_columns() {
3637        let decimal = DataType::Decimal128(38, 16);
3638        let schema = Arc::new(Schema::new(vec![
3639            Field::new("bid_price", decimal.clone(), false),
3640            Field::new("ask_price", decimal.clone(), false),
3641            Field::new("bid_size", decimal.clone(), false),
3642            Field::new("ask_size", decimal, false),
3643        ]));
3644        let values = || {
3645            Arc::new(
3646                Decimal128Array::from(vec![1_i128])
3647                    .with_precision_and_scale(38, 16)
3648                    .unwrap(),
3649            ) as ArrayRef
3650        };
3651        let batch =
3652            RecordBatch::try_new(schema.clone(), vec![values(), values(), values(), values()])
3653                .unwrap();
3654
3655        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3656
3657        assert_eq!(normalized.schema(), schema);
3658        assert_eq!(normalized, batch);
3659    }
3660
3661    #[rstest]
3662    fn normalize_legacy_depth_flat_columns_builds_structured_sides() {
3663        let mut fields = Vec::new();
3664        let mut columns = Vec::new();
3665
3666        for side in ["bid", "ask"] {
3667            for level in 0..DEPTH10_LEN {
3668                for (name, value) in [("price", 11_i128), ("size", 22_i128)] {
3669                    fields.push(Field::new(
3670                        format!("{side}_{name}_{level}"),
3671                        DataType::Decimal128(38, 16),
3672                        true,
3673                    ));
3674                    let value = (level == 0).then_some(value);
3675                    columns.push(Arc::new(
3676                        Decimal128Array::from(vec![value])
3677                            .with_precision_and_scale(38, 16)
3678                            .unwrap(),
3679                    ) as ArrayRef);
3680                }
3681                fields.push(Field::new(
3682                    format!("{side}_count_{level}"),
3683                    DataType::UInt32,
3684                    false,
3685                ));
3686                columns.push(Arc::new(UInt32Array::from(vec![33])) as ArrayRef);
3687                fields.push(Field::new(
3688                    format!("{side}_order_id_{level}"),
3689                    DataType::UInt64,
3690                    false,
3691                ));
3692                columns.push(Arc::new(UInt64Array::from(vec![44])) as ArrayRef);
3693            }
3694        }
3695        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
3696
3697        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3698
3699        assert_normalized_depth(&normalized, 1, 11, 22, 33, 44);
3700    }
3701
3702    #[rstest]
3703    fn normalize_legacy_depth_fixed_lists_builds_structured_sides() {
3704        let decimal_values = |value| {
3705            Arc::new(
3706                Decimal128Array::from(
3707                    (0..DEPTH10_LEN)
3708                        .map(|level| (level == 0).then_some(value))
3709                        .collect::<Vec<_>>(),
3710                )
3711                .with_precision_and_scale(38, 16)
3712                .unwrap(),
3713            ) as ArrayRef
3714        };
3715        let counts = Arc::new(UInt32Array::from(vec![33; DEPTH10_LEN])) as ArrayRef;
3716        let order_ids = Arc::new(UInt64Array::from(vec![44; DEPTH10_LEN])) as ArrayRef;
3717        let mut fields = Vec::new();
3718        let mut columns = Vec::new();
3719
3720        for side in ["bid", "ask"] {
3721            for (name, column) in [
3722                ("price", depth_list_array(decimal_values(11), true)),
3723                ("size", depth_list_array(decimal_values(22), true)),
3724                ("count", depth_list_array(counts.clone(), false)),
3725                ("order_id", depth_list_array(order_ids.clone(), false)),
3726            ] {
3727                fields.push(Field::new(
3728                    format!("{side}_{name}"),
3729                    column.data_type().clone(),
3730                    false,
3731                ));
3732                columns.push(column);
3733            }
3734        }
3735        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
3736
3737        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3738
3739        assert_normalized_depth(&normalized, 1, 11, 22, 33, 44);
3740    }
3741
3742    #[rstest]
3743    fn normalize_legacy_depth_missing_counts_and_order_ids_uses_list_width() {
3744        const WIDTH: i32 = 3;
3745        let decimal_values = |value| {
3746            Arc::new(
3747                Decimal128Array::from(vec![value; WIDTH as usize])
3748                    .with_precision_and_scale(38, 16)
3749                    .unwrap(),
3750            ) as ArrayRef
3751        };
3752        let list = |values: ArrayRef| {
3753            Arc::new(FixedSizeListArray::new(
3754                Arc::new(Field::new("item", values.data_type().clone(), false)),
3755                WIDTH,
3756                values,
3757                None,
3758            )) as ArrayRef
3759        };
3760        let mut fields = Vec::new();
3761        let mut columns = Vec::new();
3762
3763        for side in ["bid", "ask"] {
3764            for (name, column) in [
3765                ("price", list(decimal_values(11))),
3766                ("size", list(decimal_values(22))),
3767            ] {
3768                fields.push(Field::new(
3769                    format!("{side}_{name}"),
3770                    column.data_type().clone(),
3771                    false,
3772                ));
3773                columns.push(column);
3774            }
3775        }
3776        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
3777
3778        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3779
3780        assert_normalized_depth(&normalized, WIDTH as usize, 11, 22, 0, 0);
3781    }
3782
3783    #[rstest]
3784    #[case::with_order_ids(true, 44)]
3785    #[case::without_order_ids(false, 0)]
3786    fn normalize_legacy_depth_fixed_binary_lists_matches_schema(
3787        #[case] include_order_ids: bool,
3788        #[case] expected_order_id: u64,
3789    ) {
3790        let fixed_values = |value: [u8; 8]| {
3791            let values = (0..DEPTH10_LEN)
3792                .map(|level| (level == 0).then_some(value))
3793                .collect::<Vec<_>>();
3794            Arc::new(
3795                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
3796                    values
3797                        .iter()
3798                        .map(Option::as_ref)
3799                        .map(|value| value.map(<[u8; 8]>::as_slice)),
3800                    8,
3801                )
3802                .unwrap(),
3803            ) as ArrayRef
3804        };
3805        let counts = Arc::new(UInt32Array::from(vec![33; DEPTH10_LEN])) as ArrayRef;
3806        let order_ids = Arc::new(UInt64Array::from(vec![44; DEPTH10_LEN])) as ArrayRef;
3807        let mut fields = Vec::new();
3808        let mut columns = Vec::new();
3809
3810        for side in ["bid", "ask"] {
3811            let mut side_columns = vec![
3812                (
3813                    "price",
3814                    depth_list_array(fixed_values(11_i64.to_le_bytes()), true),
3815                ),
3816                (
3817                    "size",
3818                    depth_list_array(fixed_values(22_u64.to_le_bytes()), true),
3819                ),
3820                ("count", depth_list_array(counts.clone(), false)),
3821            ];
3822
3823            if include_order_ids {
3824                side_columns.push(("order_id", depth_list_array(order_ids.clone(), false)));
3825            }
3826
3827            for (name, column) in side_columns {
3828                fields.push(Field::new(
3829                    format!("{side}_{name}"),
3830                    column.data_type().clone(),
3831                    false,
3832                ));
3833                columns.push(column);
3834            }
3835        }
3836        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
3837
3838        assert!(is_nautilus_legacy_schema(batch.schema_ref()));
3839        let normalized_schema = normalize_legacy_parquet_schema(batch.schema_ref());
3840        let normalized_batch = normalize_legacy_parquet_columns(&batch).unwrap();
3841
3842        assert_eq!(
3843            normalized_schema,
3844            normalized_batch.schema().as_ref().clone()
3845        );
3846        assert_normalized_depth(
3847            &normalized_batch,
3848            1,
3849            110_000_000,
3850            220_000_000,
3851            33,
3852            expected_order_id,
3853        );
3854    }
3855
3856    #[rstest]
3857    fn normalize_legacy_depth_flat_fixed_columns_preserves_order_ids() {
3858        let fixed_price = || {
3859            let bytes = 11_i64.to_le_bytes();
3860            Arc::new(
3861                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
3862                    [Some(bytes.as_slice())].into_iter(),
3863                    8,
3864                )
3865                .unwrap(),
3866            ) as ArrayRef
3867        };
3868        let fixed_size = || {
3869            let bytes = 22_u64.to_le_bytes();
3870            Arc::new(
3871                FixedSizeBinaryArray::try_from_sparse_iter_with_size(
3872                    [Some(bytes.as_slice())].into_iter(),
3873                    8,
3874                )
3875                .unwrap(),
3876            ) as ArrayRef
3877        };
3878        let mut fields = Vec::new();
3879        let mut columns = Vec::new();
3880
3881        for side in ["bid", "ask"] {
3882            for level in 0..DEPTH10_LEN {
3883                fields.push(Field::new(
3884                    format!("{side}_price_{level}"),
3885                    DataType::FixedSizeBinary(8),
3886                    false,
3887                ));
3888                columns.push(fixed_price());
3889                fields.push(Field::new(
3890                    format!("{side}_size_{level}"),
3891                    DataType::FixedSizeBinary(8),
3892                    false,
3893                ));
3894                columns.push(fixed_size());
3895                fields.push(Field::new(
3896                    format!("{side}_count_{level}"),
3897                    DataType::UInt32,
3898                    false,
3899                ));
3900                columns.push(Arc::new(UInt32Array::from(vec![33])) as ArrayRef);
3901                fields.push(Field::new(
3902                    format!("{side}_order_id_{level}"),
3903                    DataType::UInt64,
3904                    false,
3905                ));
3906                columns.push(Arc::new(UInt64Array::from(vec![44])) as ArrayRef);
3907            }
3908        }
3909
3910        for (field, column) in [
3911            (
3912                Field::new("flags", DataType::UInt8, false),
3913                Arc::new(UInt8Array::from(vec![0])) as ArrayRef,
3914            ),
3915            (
3916                Field::new("sequence", DataType::UInt64, false),
3917                Arc::new(UInt64Array::from(vec![1])) as ArrayRef,
3918            ),
3919            (
3920                Field::new("ts_event", DataType::UInt64, false),
3921                Arc::new(UInt64Array::from(vec![2])) as ArrayRef,
3922            ),
3923            (
3924                Field::new("ts_init", DataType::UInt64, false),
3925                Arc::new(UInt64Array::from(vec![3])) as ArrayRef,
3926            ),
3927        ] {
3928            fields.push(field);
3929            columns.push(column);
3930        }
3931        let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
3932        assert!(is_nautilus_legacy_schema(batch.schema_ref()));
3933
3934        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3935
3936        for side in ["bids", "asks"] {
3937            let list = normalized
3938                .column_by_name(side)
3939                .unwrap()
3940                .as_any()
3941                .downcast_ref::<ListArray>()
3942                .unwrap();
3943            let levels = list.value(0);
3944            let levels = levels.as_any().downcast_ref::<StructArray>().unwrap();
3945            let order_ids = levels
3946                .column_by_name("order_id")
3947                .unwrap()
3948                .as_any()
3949                .downcast_ref::<UInt64Array>()
3950                .unwrap();
3951            assert_eq!(order_ids.values(), &[44; DEPTH10_LEN]);
3952        }
3953    }
3954
3955    #[rstest]
3956    fn normalize_legacy_depth_fixture_matches_open_shape() {
3957        let precision_dir = if cfg!(feature = "high-precision") {
3958            "128-bit"
3959        } else {
3960            "64-bit"
3961        };
3962        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3963            .join("../../test_data/nautilus/legacy")
3964            .join(precision_dir)
3965            .join("depths.parquet");
3966        let file = std::fs::File::open(path).unwrap();
3967        let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
3968        let metadata = builder
3969            .metadata()
3970            .file_metadata()
3971            .key_value_metadata()
3972            .unwrap();
3973
3974        for (key, value) in [
3975            ("instrument_id", "AAPL.XNAS"),
3976            ("price_precision", "4"),
3977            ("size_precision", "1"),
3978        ] {
3979            assert_eq!(
3980                metadata
3981                    .iter()
3982                    .find(|entry| entry.key == key)
3983                    .and_then(|entry| entry.value.as_deref()),
3984                Some(value),
3985            );
3986        }
3987        let batch = builder.build().unwrap().next().unwrap().unwrap();
3988
3989        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3990
3991        assert_normalized_depth(
3992            &normalized,
3993            DEPTH10_LEN,
3994            12_345_000_000_000_000,
3995            25_000_000_000_000_000,
3996            3,
3997            0,
3998        );
3999        assert_eq!(normalized.num_columns(), 6);
4000        assert_eq!(
4001            normalized
4002                .column_by_name("flags")
4003                .unwrap()
4004                .as_any()
4005                .downcast_ref::<UInt8Array>()
4006                .unwrap()
4007                .value(0),
4008            32
4009        );
4010        assert_eq!(
4011            normalized
4012                .column_by_name("sequence")
4013                .unwrap()
4014                .as_any()
4015                .downcast_ref::<UInt64Array>()
4016                .unwrap()
4017                .value(0),
4018            7
4019        );
4020
4021        for name in ["ts_event", "ts_init"] {
4022            assert_eq!(
4023                normalized
4024                    .schema()
4025                    .field_with_name(name)
4026                    .unwrap()
4027                    .data_type(),
4028                &DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, Some("UTC".into())),
4029            );
4030        }
4031    }
4032
4033    #[rstest]
4034    #[case("quotes.parquet", "bid_price", None)]
4035    #[case("trades.parquet", "price", Some("aggressor_side"))]
4036    #[case("bars.parquet", "open", None)]
4037    #[case("deltas.parquet", "price", Some("action"))]
4038    fn legacy_market_fixture_matches_open_types(
4039        #[case] file_name: &str,
4040        #[case] fixed_field: &str,
4041        #[case] enum_field: Option<&str>,
4042    ) {
4043        let precision_dir = if cfg!(feature = "high-precision") {
4044            "128-bit"
4045        } else {
4046            "64-bit"
4047        };
4048        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4049            .join("../../test_data/nautilus/legacy")
4050            .join(precision_dir)
4051            .join(file_name);
4052        let file = std::fs::File::open(path).unwrap();
4053        let batch = ParquetRecordBatchReaderBuilder::try_new(file)
4054            .unwrap()
4055            .build()
4056            .unwrap()
4057            .next()
4058            .unwrap()
4059            .unwrap();
4060
4061        let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
4062
4063        assert_eq!(
4064            normalized
4065                .schema()
4066                .field_with_name(fixed_field)
4067                .unwrap()
4068                .data_type(),
4069            &DataType::Decimal128(38, 16),
4070        );
4071        assert_eq!(
4072            normalized
4073                .schema()
4074                .field_with_name("ts_init")
4075                .unwrap()
4076                .data_type(),
4077            &DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, Some("UTC".into())),
4078        );
4079
4080        if let Some(enum_field) = enum_field {
4081            assert!(matches!(
4082                normalized
4083                    .schema()
4084                    .field_with_name(enum_field)
4085                    .unwrap()
4086                    .data_type(),
4087                DataType::Dictionary(_, value) if value.as_ref() == &DataType::Utf8
4088            ));
4089        }
4090    }
4091
4092    #[rstest]
4093    #[case("depths.parquet")]
4094    #[case("quotes.parquet")]
4095    #[case("trades.parquet")]
4096    #[case("bars.parquet")]
4097    #[case("deltas.parquet")]
4098    #[case("dictionary-trade")]
4099    fn legacy_fixture_schema_normalization_matches_batch(#[case] file_name: &str) {
4100        let (schema, batch) = if file_name == "dictionary-trade" {
4101            let dictionary = |value: &str| {
4102                let mut builder = StringDictionaryBuilder::<Int8Type>::new();
4103                builder.append(value).unwrap();
4104                Arc::new(builder.finish()) as ArrayRef
4105            };
4106            let price = 11_i64.to_le_bytes();
4107            let size = 22_u64.to_le_bytes();
4108            let trade_ids = dictionary("trade-1");
4109            let identifiers = dictionary("AAPL.XNAS");
4110            let schema = Arc::new(Schema::new_with_metadata(
4111                vec![
4112                    Field::new("price", DataType::FixedSizeBinary(8), false),
4113                    Field::new("size", DataType::FixedSizeBinary(8), false),
4114                    Field::new("aggressor_side", DataType::UInt8, false),
4115                    Field::new("trade_id", trade_ids.data_type().clone(), false),
4116                    Field::new("ts_event", DataType::UInt64, false),
4117                    Field::new("ts_init", DataType::UInt64, false),
4118                    Field::new(KEY_IDENTIFIER, identifiers.data_type().clone(), false),
4119                ],
4120                HashMap::from([("type".to_string(), "TradeTick".to_string())]),
4121            ));
4122            let batch = RecordBatch::try_new(
4123                Arc::clone(&schema),
4124                vec![
4125                    Arc::new(
4126                        FixedSizeBinaryArray::try_from_sparse_iter_with_size(
4127                            [Some(price.as_slice())].into_iter(),
4128                            8,
4129                        )
4130                        .unwrap(),
4131                    ),
4132                    Arc::new(
4133                        FixedSizeBinaryArray::try_from_sparse_iter_with_size(
4134                            [Some(size.as_slice())].into_iter(),
4135                            8,
4136                        )
4137                        .unwrap(),
4138                    ),
4139                    Arc::new(UInt8Array::from(vec![1])),
4140                    trade_ids,
4141                    Arc::new(UInt64Array::from(vec![1])),
4142                    Arc::new(UInt64Array::from(vec![2])),
4143                    identifiers,
4144                ],
4145            )
4146            .unwrap();
4147            (schema, batch)
4148        } else {
4149            let precision_dir = if cfg!(feature = "high-precision") {
4150                "128-bit"
4151            } else {
4152                "64-bit"
4153            };
4154            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4155                .join("../../test_data/nautilus/legacy")
4156                .join(precision_dir)
4157                .join(file_name);
4158            let file = std::fs::File::open(path).unwrap();
4159            let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
4160            let schema = builder.schema().clone();
4161            let batch = builder.build().unwrap().next().unwrap().unwrap();
4162            let batch =
4163                RecordBatch::try_new(Arc::clone(&schema), batch.columns().to_vec()).unwrap();
4164            (schema, batch)
4165        };
4166        let normalized_schema = normalize_legacy_parquet_schema(schema.as_ref());
4167        let normalized_batch = normalize_legacy_parquet_columns(&batch).unwrap();
4168
4169        assert_eq!(
4170            normalized_schema,
4171            normalized_batch.schema().as_ref().clone()
4172        );
4173
4174        if file_name == "dictionary-trade" {
4175            assert_eq!(
4176                normalized_batch
4177                    .schema()
4178                    .field_with_name("trade_id")
4179                    .unwrap()
4180                    .data_type(),
4181                &DataType::Utf8,
4182            );
4183        }
4184    }
4185
4186    fn assert_normalized_depth(
4187        batch: &RecordBatch,
4188        level_count: usize,
4189        price: i128,
4190        size: i128,
4191        count: u32,
4192        order_id: u64,
4193    ) {
4194        let schema = batch.schema();
4195        assert_eq!(schema.field(0).name(), "bids");
4196        assert_eq!(schema.field(1).name(), "asks");
4197
4198        for side in ["bids", "asks"] {
4199            let list = batch
4200                .column_by_name(side)
4201                .unwrap()
4202                .as_any()
4203                .downcast_ref::<ListArray>()
4204                .unwrap();
4205            let levels = list.value(0);
4206            let levels = levels.as_any().downcast_ref::<StructArray>().unwrap();
4207            let prices = levels
4208                .column_by_name("price")
4209                .unwrap()
4210                .as_any()
4211                .downcast_ref::<Decimal128Array>()
4212                .unwrap();
4213            let sizes = levels
4214                .column_by_name("size")
4215                .unwrap()
4216                .as_any()
4217                .downcast_ref::<Decimal128Array>()
4218                .unwrap();
4219            let counts = levels
4220                .column_by_name("count")
4221                .unwrap()
4222                .as_any()
4223                .downcast_ref::<UInt32Array>()
4224                .unwrap();
4225            let order_ids = levels
4226                .column_by_name("order_id")
4227                .unwrap()
4228                .as_any()
4229                .downcast_ref::<UInt64Array>()
4230                .unwrap();
4231
4232            assert_eq!(levels.len(), level_count);
4233            assert_eq!(prices.value(0), price);
4234            assert_eq!(sizes.value(0), size);
4235            assert_eq!(counts.value(0), count);
4236            assert_eq!(order_ids.value(0), order_id);
4237        }
4238    }
4239
4240    #[tokio::test]
4241    async fn default_writer_sets_zstd_sorting_and_identifier_bloom_filter() {
4242        let directory = tempfile::tempdir().unwrap();
4243        let path = directory.path().join("layout.parquet");
4244        let object_store = Arc::new(
4245            object_store::local::LocalFileSystem::new_with_prefix(directory.path()).unwrap(),
4246        );
4247        let object_path = ObjectPath::from("layout.parquet");
4248        let schema = Arc::new(Schema::new(vec![
4249            Field::new("identifier", DataType::Utf8, false),
4250            Field::new("ts_init", DataType::UInt64, false),
4251        ]));
4252        let batch = RecordBatch::try_new(
4253            schema,
4254            vec![
4255                Arc::new(StringArray::from(vec!["AUD/USD.SIM", "AUD/USD.SIM"])) as ArrayRef,
4256                Arc::new(UInt64Array::from(vec![1_u64, 2])) as ArrayRef,
4257            ],
4258        )
4259        .unwrap();
4260
4261        write_batches_to_object_store(&[batch], object_store, &object_path, None, None, None)
4262            .await
4263            .unwrap();
4264
4265        let read_options = ReadOptionsBuilder::new()
4266            .with_reader_properties(
4267                ReaderProperties::builder()
4268                    .set_read_bloom_filter(true)
4269                    .build(),
4270            )
4271            .build();
4272        let reader = SerializedFileReader::new_with_options(
4273            std::fs::File::open(path).unwrap(),
4274            read_options,
4275        )
4276        .unwrap();
4277        let row_group = reader.metadata().row_group(0);
4278        let sorting = row_group.sorting_columns().unwrap();
4279
4280        assert_eq!(DEFAULT_ROW_GROUP_SIZE, 131_072);
4281        assert_eq!(
4282            sorting,
4283            &vec![
4284                SortingColumn {
4285                    column_idx: 1,
4286                    descending: false,
4287                    nulls_first: false,
4288                },
4289                SortingColumn {
4290                    column_idx: 0,
4291                    descending: false,
4292                    nulls_first: false,
4293                },
4294            ],
4295        );
4296        assert!(
4297            row_group
4298                .columns()
4299                .iter()
4300                .all(|column| column.compression() == Compression::ZSTD(ZstdLevel::default())),
4301        );
4302        assert!(
4303            reader
4304                .get_row_group(0)
4305                .unwrap()
4306                .get_column_bloom_filter(0)
4307                .is_some(),
4308        );
4309    }
4310}