1#![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
93pub(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#[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
212fn 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
682pub 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
704pub 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
734pub 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
756pub 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
788pub 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 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 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
920fn 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
970pub 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 let (object_store, base_path, _) =
989 create_object_store_from_path(file_paths[0], storage_options)?;
990
991 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
1020pub 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 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 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 let batches_to_write = if deduplicate.unwrap_or(false) {
1095 deduplicate_record_batches(&all_batches)?
1096 } else {
1097 all_batches
1098 };
1099
1100 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 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
1267pub 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
1287pub 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 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 for i in 0..metadata.num_row_groups() {
1308 let row_group = metadata.row_group(i);
1309
1310 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 if let Statistics::Int64(int64_stats) = stats {
1318 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 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 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
1355pub 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#[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), }?;
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
1443pub(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
1465pub(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#[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 if let Some(options) = storage_options {
1510 for (key, value) in options {
1511 match key.as_str() {
1512 "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 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#[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 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 log::warn!(
1569 "project_id should be set via service account or environment variables"
1570 );
1571 }
1572 "application_credentials" => {
1573 unsafe {
1578 std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", &value);
1579 }
1580 }
1581 _ => {
1582 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#[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 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#[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 let account = host
1627 .split('.')
1628 .next()
1629 .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: cannot extract account from host"))?;
1630
1631 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 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#[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 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 log::warn!("Unknown {store_label} storage option: {key}");
1693 }
1694 }
1695 }
1696
1697 builder
1698}
1699
1700#[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 if let Some(options) = storage_options {
1716 for (key, _value) in options {
1717 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#[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#[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(¤t, ¤t_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(¤t, ¤t_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 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 assert_eq!(uri, format!("file://{}", temp_dir.to_str().unwrap()));
2917
2918 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 options.insert("account_key".to_string(), "dGVzdGtleQ==".to_string()); 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 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 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 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(¤t, ¤t_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(¤t, ¤t_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}