Skip to main content

nautilus_persistence/
parquet.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::sync::Arc;
17
18use ahash::AHashMap;
19use arrow::record_batch::RecordBatch;
20use object_store::{ObjectStore, ObjectStoreExt, path::Path as ObjectPath};
21use parquet::{
22    arrow::{ArrowWriter, arrow_reader::ParquetRecordBatchReaderBuilder},
23    file::{
24        metadata::KeyValue,
25        properties::WriterProperties,
26        reader::{FileReader, SerializedFileReader},
27        statistics::Statistics,
28    },
29};
30use url::Url;
31
32pub(crate) fn is_remote_uri_scheme(scheme: &str) -> bool {
33    matches!(
34        scheme,
35        "s3" | "gs" | "gcs" | "az" | "abfs" | "http" | "https"
36    )
37}
38
39pub(crate) fn remote_store_root_url(uri: &str) -> anyhow::Result<Url> {
40    let mut url = Url::parse(uri)?;
41    url.set_path("");
42    url.set_query(None);
43    url.set_fragment(None);
44    Ok(url)
45}
46
47pub(crate) fn remote_full_uri(uri: &str, object_path: &str) -> anyhow::Result<String> {
48    let root = remote_store_root_url(uri)?;
49    let root = root.as_str().trim_end_matches('/');
50    let object_path = object_path.trim_start_matches('/');
51
52    if object_path.is_empty() {
53        Ok(root.to_string())
54    } else {
55        Ok(format!("{root}/{object_path}"))
56    }
57}
58
59pub(crate) enum ObjectStoreLocationKind {
60    Local,
61    Remote { store_root_url: Url },
62}
63
64pub(crate) struct ObjectStoreLocation {
65    pub object_store: Arc<dyn ObjectStore>,
66    pub base_path: String,
67    pub original_uri: String,
68    pub kind: ObjectStoreLocationKind,
69}
70
71impl ObjectStoreLocation {
72    pub(crate) fn store_root_url(&self) -> Option<&Url> {
73        match &self.kind {
74            ObjectStoreLocationKind::Local => None,
75            ObjectStoreLocationKind::Remote { store_root_url } => Some(store_root_url),
76        }
77    }
78}
79
80/// Writes a `RecordBatch` to a Parquet file using object store, with optional compression.
81///
82/// # Errors
83///
84/// Returns an error if writing to Parquet fails or any I/O operation fails.
85pub async fn write_batch_to_parquet(
86    batch: RecordBatch,
87    path: &str,
88    storage_options: Option<AHashMap<String, String>>,
89    compression: Option<parquet::basic::Compression>,
90    max_row_group_size: Option<usize>,
91) -> anyhow::Result<()> {
92    write_batches_to_parquet(
93        &[batch],
94        path,
95        storage_options,
96        compression,
97        max_row_group_size,
98    )
99    .await
100}
101
102/// Writes multiple `RecordBatch` items to a Parquet file using object store, with optional compression, row group sizing, and storage options.
103///
104/// # Errors
105///
106/// Returns an error if writing to Parquet fails or any I/O operation fails.
107pub async fn write_batches_to_parquet(
108    batches: &[RecordBatch],
109    path: &str,
110    storage_options: Option<AHashMap<String, String>>,
111    compression: Option<parquet::basic::Compression>,
112    max_row_group_size: Option<usize>,
113) -> anyhow::Result<()> {
114    let (object_store, base_path, _) = create_object_store_from_path(path, storage_options)?;
115    let object_path = if base_path.is_empty() {
116        ObjectPath::from(path)
117    } else {
118        ObjectPath::from(format!("{base_path}/{path}"))
119    };
120
121    write_batches_to_object_store(
122        batches,
123        object_store,
124        &object_path,
125        compression,
126        max_row_group_size,
127        None,
128    )
129    .await
130}
131
132/// Reads a Parquet file from an object store and returns all record batches plus
133/// the Arrow schema from the builder. The builder's schema includes metadata restored
134/// from the file's `ARROW:schema` `key_value_metadata`; use it for decoding instead of
135/// each batch's schema (which has metadata stripped).
136///
137/// # Errors
138///
139/// Returns an error if the path cannot be read or Parquet parsing fails.
140pub async fn read_parquet_from_object_store(
141    object_store: Arc<dyn ObjectStore>,
142    path: &ObjectPath,
143) -> anyhow::Result<(Vec<RecordBatch>, Arc<arrow::datatypes::Schema>)> {
144    let result: object_store::GetResult = object_store.get(path).await?;
145    let data = result.bytes().await?;
146    if data.is_empty() {
147        return Ok((
148            Vec::new(),
149            Arc::new(arrow::datatypes::Schema::new(
150                Vec::<arrow::datatypes::Field>::new(),
151            )),
152        ));
153    }
154    let builder = ParquetRecordBatchReaderBuilder::try_new(data)?;
155    let schema = builder.schema().clone();
156    let reader = builder.build()?;
157    let mut batches = Vec::new();
158    for batch in reader {
159        batches.push(batch?);
160    }
161    Ok((batches, schema))
162}
163
164/// Writes multiple `RecordBatch` items to an object store URI, with optional compression,
165/// row group sizing, and `key_value_metadata` (e.g. for instrument "class" so it survives roundtrip).
166///
167/// # Errors
168///
169/// Returns an error if writing to Parquet fails or any I/O operation fails.
170pub async fn write_batches_to_object_store(
171    batches: &[RecordBatch],
172    object_store: Arc<dyn ObjectStore>,
173    path: &ObjectPath,
174    compression: Option<parquet::basic::Compression>,
175    max_row_group_size: Option<usize>,
176    key_value_metadata: Option<Vec<KeyValue>>,
177) -> anyhow::Result<()> {
178    // Create a temporary buffer to write the parquet data
179    let mut buffer = Vec::new();
180
181    let mut props_builder = WriterProperties::builder()
182        .set_compression(compression.unwrap_or(parquet::basic::Compression::SNAPPY))
183        .set_max_row_group_row_count(Some(max_row_group_size.unwrap_or(5000)));
184
185    if let Some(kv) = key_value_metadata {
186        props_builder = props_builder.set_key_value_metadata(Some(kv));
187    }
188    let writer_props = props_builder.build();
189
190    let mut writer = ArrowWriter::try_new(&mut buffer, batches[0].schema(), Some(writer_props))?;
191    for batch in batches {
192        writer.write(batch)?;
193    }
194    writer.close()?;
195
196    // Upload the buffer to object store
197    object_store.put(path, buffer.into()).await?;
198
199    Ok(())
200}
201
202/// Deduplicates a slice of `RecordBatch` items, removing rows that are identical across all columns.
203///
204/// Rows are compared by encoding each row to a canonical byte sequence using Arrow's row format.
205/// Only the first occurrence of each unique row is retained; the relative order of unique rows
206/// is preserved.
207///
208/// # Errors
209///
210/// Returns an error if the row converter cannot be constructed or if the `take` kernel fails.
211fn deduplicate_record_batches(batches: &[RecordBatch]) -> anyhow::Result<Vec<RecordBatch>> {
212    if batches.is_empty() {
213        return Ok(Vec::new());
214    }
215
216    let schema = batches[0].schema();
217
218    let fields: Vec<arrow_row::SortField> = schema
219        .fields()
220        .iter()
221        .map(|f| arrow_row::SortField::new(f.data_type().clone()))
222        .collect();
223
224    let converter = arrow_row::RowConverter::new(fields)?;
225    let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
226    let mut result: Vec<RecordBatch> = Vec::new();
227
228    for batch in batches {
229        let rows = converter.convert_columns(batch.columns())?;
230        let mut indices: Vec<u32> = Vec::new();
231
232        for (i, row) in rows.iter().enumerate() {
233            if seen.insert(row.as_ref().to_vec()) {
234                indices.push(
235                    u32::try_from(i)
236                        .map_err(|_| anyhow::anyhow!("record batch row index exceeds u32"))?,
237                );
238            }
239        }
240
241        if !indices.is_empty() {
242            let index_array = arrow::array::UInt32Array::from(indices);
243            let deduped_columns: Vec<arrow::array::ArrayRef> = batch
244                .columns()
245                .iter()
246                .map(|col| arrow::compute::take(col.as_ref(), &index_array, None))
247                .collect::<Result<_, _>>()?;
248            result.push(RecordBatch::try_new(schema.clone(), deduped_columns)?);
249        }
250    }
251
252    Ok(result)
253}
254
255/// Combines multiple Parquet files using object store with storage options
256///
257/// # Errors
258///
259/// Returns an error if file reading or writing fails.
260pub async fn combine_parquet_files(
261    file_paths: Vec<&str>,
262    new_file_path: &str,
263    storage_options: Option<AHashMap<String, String>>,
264    compression: Option<parquet::basic::Compression>,
265    max_row_group_size: Option<usize>,
266    deduplicate: Option<bool>,
267) -> anyhow::Result<()> {
268    if file_paths.len() <= 1 {
269        return Ok(());
270    }
271
272    // Create object store from the first file path (assuming all files are in the same store)
273    let (object_store, base_path, _) =
274        create_object_store_from_path(file_paths[0], storage_options)?;
275
276    // Convert string paths to ObjectPath
277    let object_paths: Vec<ObjectPath> = file_paths
278        .iter()
279        .map(|path| {
280            if base_path.is_empty() {
281                ObjectPath::from(*path)
282            } else {
283                ObjectPath::from(format!("{base_path}/{path}"))
284            }
285        })
286        .collect();
287
288    let new_object_path = if base_path.is_empty() {
289        ObjectPath::from(new_file_path)
290    } else {
291        ObjectPath::from(format!("{base_path}/{new_file_path}"))
292    };
293
294    combine_parquet_files_from_object_store(
295        object_store,
296        object_paths,
297        &new_object_path,
298        compression,
299        max_row_group_size,
300        deduplicate,
301    )
302    .await
303}
304
305/// Combines multiple Parquet files from object store
306///
307/// # Errors
308///
309/// Returns an error if file reading or writing fails.
310pub async fn combine_parquet_files_from_object_store(
311    object_store: Arc<dyn ObjectStore>,
312    file_paths: Vec<ObjectPath>,
313    new_file_path: &ObjectPath,
314    compression: Option<parquet::basic::Compression>,
315    max_row_group_size: Option<usize>,
316    deduplicate: Option<bool>,
317) -> anyhow::Result<()> {
318    if file_paths.len() <= 1 {
319        return Ok(());
320    }
321
322    let mut all_batches: Vec<RecordBatch> = Vec::new();
323    let mut schema_with_metadata: Option<Arc<arrow::datatypes::Schema>> = None;
324
325    // Read all files from object store
326    for path in &file_paths {
327        let result: object_store::GetResult = object_store.get(path).await?;
328        let data = result.bytes().await?;
329        let builder = ParquetRecordBatchReaderBuilder::try_new(data)?;
330
331        // Capture the schema from the first file's builder; it includes the Arrow
332        // schema-level metadata (e.g. bar_type, instrument_id) restored from the
333        // Parquet ARROW:schema key_value_metadata entry.  Individual RecordBatch
334        // objects returned by the reader have this metadata stripped, so we need
335        // to preserve it separately and re-apply it when writing the combined file.
336        if schema_with_metadata.is_none() {
337            schema_with_metadata = Some(builder.schema().clone());
338        }
339
340        let mut reader = builder.build()?;
341
342        for batch in reader.by_ref() {
343            all_batches.push(batch?);
344        }
345    }
346
347    // Re-apply the preserved schema metadata to all collected batches so that
348    // write_batches_to_object_store (which uses batches[0].schema()) can encode
349    // the correct Arrow schema metadata into the combined output file.
350    if let Some(schema) = &schema_with_metadata {
351        all_batches = all_batches
352            .into_iter()
353            .map(|batch| RecordBatch::try_new(schema.clone(), batch.columns().to_vec()))
354            .collect::<Result<_, _>>()?;
355    }
356
357    // Deduplicate rows if requested
358    let batches_to_write = if deduplicate.unwrap_or(false) {
359        deduplicate_record_batches(&all_batches)?
360    } else {
361        all_batches
362    };
363
364    // Write combined batches to new location
365    write_batches_to_object_store(
366        &batches_to_write,
367        object_store.clone(),
368        new_file_path,
369        compression,
370        max_row_group_size,
371        None,
372    )
373    .await?;
374
375    // Remove the merged files
376    for path in &file_paths {
377        if path != new_file_path {
378            object_store.delete(path).await?;
379        }
380    }
381
382    Ok(())
383}
384
385/// Extracts the minimum and maximum i64 values for the specified `column_name` from a Parquet file's metadata using object store with storage options.
386///
387/// # Errors
388///
389/// Returns an error if the file cannot be read, metadata parsing fails, or the column is missing or has no statistics.
390pub async fn min_max_from_parquet_metadata(
391    file_path: &str,
392    storage_options: Option<AHashMap<String, String>>,
393    column_name: &str,
394) -> anyhow::Result<(u64, u64)> {
395    let (object_store, base_path, _) = create_object_store_from_path(file_path, storage_options)?;
396    let object_path = if base_path.is_empty() {
397        ObjectPath::from(file_path)
398    } else {
399        ObjectPath::from(format!("{base_path}/{file_path}"))
400    };
401
402    min_max_from_parquet_metadata_object_store(object_store, &object_path, column_name).await
403}
404
405/// Extracts the minimum and maximum i64 values for the specified `column_name` from a Parquet file's metadata in object store.
406///
407/// # Errors
408///
409/// Returns an error if the file cannot be read, metadata parsing fails, or the column is missing or has no statistics.
410pub async fn min_max_from_parquet_metadata_object_store(
411    object_store: Arc<dyn ObjectStore>,
412    file_path: &ObjectPath,
413    column_name: &str,
414) -> anyhow::Result<(u64, u64)> {
415    // Download the parquet file from object store
416    let result: object_store::GetResult = object_store.get(file_path).await?;
417    let data = result.bytes().await?;
418    let reader = SerializedFileReader::new(data)?;
419
420    let metadata = reader.metadata();
421    let mut overall_min_value: Option<i64> = None;
422    let mut overall_max_value: Option<i64> = None;
423
424    // Iterate through all row groups
425    for i in 0..metadata.num_row_groups() {
426        let row_group = metadata.row_group(i);
427
428        // Iterate through all columns in this row group
429        for j in 0..row_group.num_columns() {
430            let col_metadata = row_group.column(j);
431
432            if col_metadata.column_path().string() == column_name {
433                if let Some(stats) = col_metadata.statistics() {
434                    // Check if we have Int64 statistics
435                    if let Statistics::Int64(int64_stats) = stats {
436                        // Extract min value if available
437                        if let Some(&min_value) = int64_stats.min_opt()
438                            && overall_min_value.is_none_or(|overall_min| min_value < overall_min)
439                        {
440                            overall_min_value = Some(min_value);
441                        }
442
443                        // Extract max value if available
444                        if let Some(&max_value) = int64_stats.max_opt()
445                            && overall_max_value.is_none_or(|overall_max| max_value > overall_max)
446                        {
447                            overall_max_value = Some(max_value);
448                        }
449                    } else {
450                        anyhow::bail!("Warning: Column name '{column_name}' is not of type i64.");
451                    }
452                } else {
453                    anyhow::bail!(
454                        "Warning: Statistics not available for column '{column_name}' in row group {i}."
455                    );
456                }
457            }
458        }
459    }
460
461    // Return the min/max pair if both are available
462    if let (Some(min), Some(max)) = (overall_min_value, overall_max_value) {
463        Ok((
464            u64::try_from(min).map_err(|_| {
465                anyhow::anyhow!("Negative minimum value {min} for column '{column_name}'")
466            })?,
467            u64::try_from(max).map_err(|_| {
468                anyhow::anyhow!("Negative maximum value {max} for column '{column_name}'")
469            })?,
470        ))
471    } else {
472        anyhow::bail!(
473            "Column '{column_name}' not found or has no Int64 statistics in any row group."
474        )
475    }
476}
477
478/// Creates an object store from a URI string with optional storage options.
479///
480/// Supports multiple cloud storage providers:
481/// - AWS S3: `s3://bucket/path`
482/// - Google Cloud Storage: `gs://bucket/path` or `gcs://bucket/path`
483/// - Azure Blob Storage: `az://account/container/path` or `abfs://container@account.dfs.core.windows.net/path`
484/// - HTTP/WebDAV: `http://` or `https://`
485/// - Local files: `file://path` or plain paths
486///
487/// # Parameters
488///
489/// - `path`: The URI string for the storage location.
490/// - `storage_options`: Optional `HashMap` containing storage-specific configuration options:
491///   - For S3: `endpoint_url`, region, `access_key_id`, `secret_access_key`, `session_token`, etc.
492///   - For GCS: `service_account_path`, `service_account_key`, `project_id`, etc.
493///   - For Azure: `account_name`, `account_key`, `sas_token`, etc.
494///
495/// Returns a tuple of (`ObjectStore`, `base_path`, `normalized_uri`)
496///
497/// # Errors
498///
499/// Returns an error if the object store URI cannot be normalized or the
500/// backend cannot be created.
501pub fn create_object_store_from_path(
502    path: &str,
503    storage_options: Option<AHashMap<String, String>>,
504) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
505    let location = create_object_store_location_from_path(path, storage_options)?;
506    Ok((
507        location.object_store,
508        location.base_path,
509        location.original_uri,
510    ))
511}
512
513// `storage_options` is only consumed by the cloud-feature arms,
514// so keep the allow scoped to the no-cloud build.
515#[cfg_attr(
516    not(feature = "cloud"),
517    allow(unused_variables, clippy::needless_pass_by_value)
518)]
519pub(crate) fn create_object_store_location_from_path(
520    path: &str,
521    storage_options: Option<AHashMap<String, String>>,
522) -> anyhow::Result<ObjectStoreLocation> {
523    let uri = normalize_path_to_uri(path);
524
525    let (object_store, base_path, original_uri) = match uri.as_str() {
526        #[cfg(feature = "cloud")]
527        s if s.starts_with("s3://") => create_s3_store(&uri, storage_options),
528        #[cfg(feature = "cloud")]
529        s if s.starts_with("gs://") || s.starts_with("gcs://") => {
530            create_gcs_store(&uri, storage_options)
531        }
532        #[cfg(feature = "cloud")]
533        s if s.starts_with("az://") => create_azure_store(&uri, storage_options),
534        #[cfg(feature = "cloud")]
535        s if s.starts_with("abfs://") => create_abfs_store(&uri, storage_options),
536        #[cfg(feature = "cloud")]
537        s if s.starts_with("http://") || s.starts_with("https://") => {
538            create_http_store(&uri, storage_options)
539        }
540        #[cfg(not(feature = "cloud"))]
541        s if s.starts_with("s3://")
542            || s.starts_with("gs://")
543            || s.starts_with("gcs://")
544            || s.starts_with("az://")
545            || s.starts_with("abfs://")
546            || s.starts_with("http://")
547            || s.starts_with("https://") =>
548        {
549            anyhow::bail!("Cloud storage support requires the 'cloud' feature: {uri}")
550        }
551        s if s.starts_with("file://") => create_local_store(&uri, true),
552        _ => create_local_store(&uri, false), // Fallback: assume local path
553    }?;
554
555    let kind = Url::parse(&original_uri)
556        .ok()
557        .filter(|url| is_remote_uri_scheme(url.scheme()))
558        .map(|_| {
559            remote_store_root_url(&original_uri)
560                .map(|store_root_url| ObjectStoreLocationKind::Remote { store_root_url })
561        })
562        .transpose()?
563        .unwrap_or(ObjectStoreLocationKind::Local);
564
565    Ok(ObjectStoreLocation {
566        object_store,
567        base_path,
568        original_uri,
569        kind,
570    })
571}
572
573/// Normalizes a path to URI format for consistent object store usage.
574///
575/// If the path is already a URI (contains "://"), returns it as-is.
576/// Otherwise, converts local paths to file:// URIs with proper cross-platform handling.
577///
578/// Supported URI schemes:
579/// - `s3://` for AWS S3
580/// - `gs://` or `gcs://` for Google Cloud Storage
581/// - `az://` or `abfs://` for Azure Blob Storage
582/// - `http://` or `https://` for HTTP/WebDAV
583/// - `file://` for local files
584///
585/// # Cross-platform Path Handling
586///
587/// - Unix absolute paths: `/path/to/file` → `file:///path/to/file`
588/// - Windows drive paths: `C:\path\to\file` → `file:///C:/path/to/file`
589/// - Windows UNC paths: `\\server\share\file` → `file://server/share/file`
590/// - Relative paths: converted to absolute using current directory
591#[must_use]
592pub fn normalize_path_to_uri(path: &str) -> String {
593    if path.contains("://") {
594        // Already a URI - return as-is
595        path.to_string()
596    } else {
597        // Convert local path to file:// URI with cross-platform support
598        if is_absolute_path(path) {
599            path_to_file_uri(path)
600        } else {
601            // Relative path - make it absolute first
602            let absolute_path = std::env::current_dir()
603                .map_or_else(|_| std::path::PathBuf::from(path), |cwd| cwd.join(path));
604            path_to_file_uri(&absolute_path.to_string_lossy())
605        }
606    }
607}
608
609/// Checks if a path is absolute on the current platform.
610#[must_use]
611fn is_absolute_path(path: &str) -> bool {
612    if path.starts_with('/') {
613        // Unix absolute path
614        true
615    } else if path.len() >= 3
616        && path.chars().nth(1) == Some(':')
617        && path.chars().nth(2) == Some('\\')
618    {
619        // Windows drive path like C:\
620        true
621    } else if path.len() >= 3
622        && path.chars().nth(1) == Some(':')
623        && path.chars().nth(2) == Some('/')
624    {
625        // Windows drive path with forward slashes like C:/
626        true
627    } else if path.starts_with("\\\\") {
628        // Windows UNC path
629        true
630    } else {
631        false
632    }
633}
634
635/// Converts an absolute path to a file:// URI with proper platform handling.
636#[must_use]
637fn path_to_file_uri(path: &str) -> String {
638    if path.starts_with('/') {
639        // Unix absolute path
640        format!("file://{path}")
641    } else if path.len() >= 3 && path.chars().nth(1) == Some(':') {
642        // Windows drive path - normalize separators and add proper prefix
643        let normalized = path.replace('\\', "/");
644        format!("file:///{normalized}")
645    } else if let Some(without_prefix) = path.strip_prefix("\\\\") {
646        // Windows UNC path \\server\share -> file://server/share
647        let normalized = without_prefix.replace('\\', "/");
648        format!("file://{normalized}")
649    } else {
650        // Fallback - treat as relative to root
651        format!("file://{path}")
652    }
653}
654
655/// Converts a file:// URI to a native path for the current platform.
656/// On Windows, "file:///C:/x/y" becomes "C:\x\y" so LocalFileSystem and std::fs work correctly.
657#[cfg(windows)]
658pub(crate) fn file_uri_to_native_path(uri: &str) -> String {
659    let without_scheme = uri
660        .strip_prefix("file://")
661        .or_else(|| uri.strip_prefix("file:"))
662        .unwrap_or(uri);
663    // Strip leading slash so "/C:/x/y" -> "C:/x/y", then use native separators
664    let without_leading = without_scheme.trim_start_matches('/');
665    without_leading.replace('/', "\\")
666}
667
668/// Converts a file:// URI to a path string for Unix (no-op; `object_store` accepts slash paths).
669#[cfg(not(windows))]
670pub(crate) fn file_uri_to_native_path(uri: &str) -> String {
671    uri.strip_prefix("file://").unwrap_or(uri).to_string()
672}
673
674/// Appends an object-store-relative `path` onto a `file://` `base_uri`, percent-encoding
675/// each path segment.
676///
677/// The catalog records instrument directories under their object-store path form, which
678/// preserves any percent-encoding applied for the on-disk name: each non-ASCII byte in an
679/// instrument id becomes a `%XX` sequence, so the directory differs from the logical id.
680/// DataFusion percent-decodes the `ListingTableUrl` it is given, so the segments are encoded
681/// here for that decode to recover the literal on-disk name. Falls back to a plain join when
682/// `base_uri` does not parse.
683pub(crate) fn append_path_to_file_uri(base_uri: &str, path: &str) -> String {
684    if let Ok(mut url) = Url::parse(base_uri) {
685        if let Ok(mut segments) = url.path_segments_mut() {
686            segments.pop_if_empty();
687            segments.extend(
688                path.trim_end_matches('/')
689                    .split('/')
690                    .filter(|segment| !segment.is_empty()),
691            );
692        }
693        return url.to_string();
694    }
695
696    format!(
697        "{}/{}",
698        base_uri.trim_end_matches('/'),
699        path.trim_end_matches('/')
700    )
701}
702
703/// Decodes a percent-encoded `object_store` path segment back to its logical form.
704///
705/// `object_store` lists path segments in URL-encoded form, so a non-ASCII instrument
706/// directory reads back with each non-ASCII byte as a `%XX` sequence. Decoding recovers the
707/// original id for matching against `urisafe_instrument_id`. Returns the input unchanged when
708/// it is not valid percent-encoded UTF-8.
709pub(crate) fn decode_object_store_segment(segment: &str) -> String {
710    object_store::path::Path::from_url_path(segment)
711        .map_or_else(|_| segment.to_string(), String::from)
712}
713
714/// Helper function to create local file system object store
715fn create_local_store(
716    uri: &str,
717    is_file_uri: bool,
718) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
719    let path = if is_file_uri {
720        file_uri_to_native_path(uri)
721    } else {
722        uri.to_string()
723    };
724
725    let local_store = object_store::local::LocalFileSystem::new_with_prefix(&path)?;
726    Ok((Arc::new(local_store), String::new(), uri.to_string()))
727}
728
729/// Helper function to create S3 object store with options.
730#[cfg(feature = "cloud")]
731fn create_s3_store(
732    uri: &str,
733    storage_options: Option<AHashMap<String, String>>,
734) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
735    let (url, path) = parse_url_and_path(uri)?;
736    let bucket = extract_host(&url, "Invalid S3 URI: missing bucket")?;
737
738    let mut builder = object_store::aws::AmazonS3Builder::new().with_bucket_name(&bucket);
739
740    // Apply storage options if provided
741    if let Some(options) = storage_options {
742        for (key, value) in options {
743            match key.as_str() {
744                "endpoint_url" => {
745                    builder = builder.with_endpoint(&value);
746                }
747                "region" => {
748                    builder = builder.with_region(&value);
749                }
750                "access_key_id" | "key" => {
751                    builder = builder.with_access_key_id(&value);
752                }
753                "secret_access_key" | "secret" => {
754                    builder = builder.with_secret_access_key(&value);
755                }
756                "session_token" | "token" => {
757                    builder = builder.with_token(&value);
758                }
759                "allow_http" => {
760                    let allow_http = value.to_lowercase() == "true";
761                    builder = builder.with_allow_http(allow_http);
762                }
763                _ => {
764                    // Ignore unknown options for forward compatibility
765                    log::warn!("Unknown S3 storage option: {key}");
766                }
767            }
768        }
769    }
770
771    let s3_store = builder.build()?;
772    Ok((Arc::new(s3_store), path, uri.to_string()))
773}
774
775/// Helper function to create GCS object store with options.
776#[cfg(feature = "cloud")]
777fn create_gcs_store(
778    uri: &str,
779    storage_options: Option<AHashMap<String, String>>,
780) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
781    let (url, path) = parse_url_and_path(uri)?;
782    let bucket = extract_host(&url, "Invalid GCS URI: missing bucket")?;
783
784    let mut builder = object_store::gcp::GoogleCloudStorageBuilder::new().with_bucket_name(&bucket);
785
786    // Apply storage options if provided
787    if let Some(options) = storage_options {
788        for (key, value) in options {
789            match key.as_str() {
790                "service_account_path" => {
791                    builder = builder.with_service_account_path(&value);
792                }
793                "service_account_key" => {
794                    builder = builder.with_service_account_key(&value);
795                }
796                "project_id" => {
797                    // Note: GoogleCloudStorageBuilder doesn't have with_project_id method
798                    // This would need to be handled via environment variables or service account
799                    log::warn!(
800                        "project_id should be set via service account or environment variables"
801                    );
802                }
803                "application_credentials" => {
804                    // Set GOOGLE_APPLICATION_CREDENTIALS env var required by Google auth libraries.
805                    // SAFETY: std::env::set_var is marked unsafe because it mutates global state and
806                    // can break signal-safe code. We only call it during configuration before any
807                    // multi-threaded work starts, so it is considered safe in this context.
808                    unsafe {
809                        std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", &value);
810                    }
811                }
812                _ => {
813                    // Ignore unknown options for forward compatibility
814                    log::warn!("Unknown GCS storage option: {key}");
815                }
816            }
817        }
818    }
819
820    let gcs_store = builder.build()?;
821    Ok((Arc::new(gcs_store), path, uri.to_string()))
822}
823
824/// Helper function to create Azure object store with options.
825#[cfg(feature = "cloud")]
826fn create_azure_store(
827    uri: &str,
828    storage_options: Option<AHashMap<String, String>>,
829) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
830    let (url, _) = parse_url_and_path(uri)?;
831    let container = extract_host(&url, "Invalid Azure URI: missing container")?;
832
833    let path = url.path().trim_start_matches('/').to_string();
834
835    let mut builder =
836        object_store::azure::MicrosoftAzureBuilder::new().with_container_name(container);
837
838    // Apply storage options if provided
839    if let Some(options) = storage_options {
840        for (key, value) in options {
841            match key.as_str() {
842                "account_name" => {
843                    builder = builder.with_account(&value);
844                }
845                "account_key" => {
846                    builder = builder.with_access_key(&value);
847                }
848                "sas_token" => {
849                    // Parse SAS token as query string parameters
850                    let query_pairs: Vec<(String, String)> = value
851                        .split('&')
852                        .filter_map(|pair| {
853                            let mut parts = pair.split('=');
854                            match (parts.next(), parts.next()) {
855                                (Some(key), Some(val)) => Some((key.to_string(), val.to_string())),
856                                _ => None,
857                            }
858                        })
859                        .collect();
860                    builder = builder.with_sas_authorization(query_pairs);
861                }
862                "client_id" => {
863                    builder = builder.with_client_id(&value);
864                }
865                "client_secret" => {
866                    builder = builder.with_client_secret(&value);
867                }
868                "tenant_id" => {
869                    builder = builder.with_tenant_id(&value);
870                }
871                _ => {
872                    // Ignore unknown options for forward compatibility
873                    log::warn!("Unknown Azure storage option: {key}");
874                }
875            }
876        }
877    }
878
879    let azure_store = builder.build()?;
880    Ok((Arc::new(azure_store), path, uri.to_string()))
881}
882
883/// Helper function to create Azure object store from abfs:// URI with options.
884#[cfg(feature = "cloud")]
885fn create_abfs_store(
886    uri: &str,
887    storage_options: Option<AHashMap<String, String>>,
888) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
889    let (url, path) = parse_url_and_path(uri)?;
890    let host = extract_host(&url, "Invalid ABFS URI: missing host")?;
891
892    // Extract account from host (account.dfs.core.windows.net)
893    let account = host
894        .split('.')
895        .next()
896        .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: cannot extract account from host"))?;
897
898    // Extract container from username part
899    let container = url
900        .username()
901        .split('@')
902        .next()
903        .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: missing container"))?;
904
905    let mut builder = object_store::azure::MicrosoftAzureBuilder::new()
906        .with_account(account)
907        .with_container_name(container);
908
909    // Apply storage options if provided (same as Azure store)
910    if let Some(options) = storage_options {
911        for (key, value) in options {
912            match key.as_str() {
913                "account_name" => {
914                    builder = builder.with_account(&value);
915                }
916                "account_key" => {
917                    builder = builder.with_access_key(&value);
918                }
919                "sas_token" => {
920                    // Parse SAS token as query string parameters
921                    let query_pairs: Vec<(String, String)> = value
922                        .split('&')
923                        .filter_map(|pair| {
924                            let mut parts = pair.split('=');
925                            match (parts.next(), parts.next()) {
926                                (Some(key), Some(val)) => Some((key.to_string(), val.to_string())),
927                                _ => None,
928                            }
929                        })
930                        .collect();
931                    builder = builder.with_sas_authorization(query_pairs);
932                }
933                "client_id" => {
934                    builder = builder.with_client_id(&value);
935                }
936                "client_secret" => {
937                    builder = builder.with_client_secret(&value);
938                }
939                "tenant_id" => {
940                    builder = builder.with_tenant_id(&value);
941                }
942                _ => {
943                    // Ignore unknown options for forward compatibility
944                    log::warn!("Unknown ABFS storage option: {key}");
945                }
946            }
947        }
948    }
949
950    let azure_store = builder.build()?;
951    Ok((Arc::new(azure_store), path, uri.to_string()))
952}
953
954/// Helper function to create HTTP object store with options.
955#[cfg(feature = "cloud")]
956fn create_http_store(
957    uri: &str,
958    storage_options: Option<AHashMap<String, String>>,
959) -> anyhow::Result<(Arc<dyn ObjectStore>, String, String)> {
960    let (_, path) = parse_url_and_path(uri)?;
961    let base_url = remote_store_root_url(uri)?
962        .as_str()
963        .trim_end_matches('/')
964        .to_string();
965
966    let builder = object_store::http::HttpBuilder::new().with_url(base_url);
967
968    // Apply storage options if provided
969    if let Some(options) = storage_options {
970        for (key, _value) in options {
971            // HTTP builder has limited configuration options
972            // Most HTTP-specific options would be handled via client options
973            // Ignore unknown options for forward compatibility
974            log::warn!("Unknown HTTP storage option: {key}");
975        }
976    }
977
978    let http_store = builder.build()?;
979    Ok((Arc::new(http_store), path, uri.to_string()))
980}
981
982/// Helper function to parse URL and extract path component.
983#[cfg(feature = "cloud")]
984fn parse_url_and_path(uri: &str) -> anyhow::Result<(url::Url, String)> {
985    let url = url::Url::parse(uri)?;
986    let path = url.path().trim_start_matches('/').to_string();
987    Ok((url, path))
988}
989
990/// Helper function to extract host from URL with error handling.
991#[cfg(feature = "cloud")]
992fn extract_host(url: &url::Url, error_msg: &str) -> anyhow::Result<String> {
993    url.host_str()
994        .map(ToString::to_string)
995        .ok_or_else(|| anyhow::anyhow!("{error_msg}"))
996}
997
998#[cfg(test)]
999mod tests {
1000    #[cfg(feature = "cloud")]
1001    use ahash::AHashMap;
1002    use arrow::{
1003        array::Int64Array,
1004        datatypes::{DataType, Field, Schema},
1005    };
1006    use rstest::rstest;
1007
1008    use super::*;
1009
1010    #[rstest]
1011    fn test_create_object_store_from_path_local() {
1012        // Create a temporary directory for testing
1013        let temp_dir = std::env::temp_dir().join("nautilus_test");
1014        std::fs::create_dir_all(&temp_dir).unwrap();
1015
1016        let result = create_object_store_from_path(temp_dir.to_str().unwrap(), None);
1017        if let Err(e) = &result {
1018            println!("Error: {e:?}");
1019        }
1020        assert!(result.is_ok());
1021        let (_, base_path, uri) = result.unwrap();
1022        assert_eq!(base_path, "");
1023        // The URI should be normalized to file:// format
1024        assert_eq!(uri, format!("file://{}", temp_dir.to_str().unwrap()));
1025
1026        // Clean up
1027        std::fs::remove_dir_all(&temp_dir).ok();
1028    }
1029
1030    #[rstest]
1031    #[cfg(feature = "cloud")]
1032    fn test_create_object_store_from_path_s3() {
1033        let mut options = AHashMap::new();
1034        options.insert(
1035            "endpoint_url".to_string(),
1036            "https://test.endpoint.com".to_string(),
1037        );
1038        options.insert("region".to_string(), "us-west-2".to_string());
1039        options.insert("access_key_id".to_string(), "test_key".to_string());
1040        options.insert("secret_access_key".to_string(), "test_secret".to_string());
1041
1042        let result = create_object_store_from_path("s3://test-bucket/path", Some(options));
1043        assert!(result.is_ok());
1044        let (_, base_path, uri) = result.unwrap();
1045        assert_eq!(base_path, "path");
1046        assert_eq!(uri, "s3://test-bucket/path");
1047    }
1048
1049    #[rstest]
1050    #[cfg(feature = "cloud")]
1051    fn test_create_object_store_from_path_azure() {
1052        let mut options = AHashMap::new();
1053        options.insert("account_name".to_string(), "testaccount".to_string());
1054        // Use a valid base64 encoded key for testing
1055        options.insert("account_key".to_string(), "dGVzdGtleQ==".to_string()); // "testkey" in base64
1056
1057        let result = create_object_store_from_path("az://container/path", Some(options));
1058        if let Err(e) = &result {
1059            println!("Azure Error: {e:?}");
1060        }
1061        assert!(result.is_ok());
1062        let (_, base_path, uri) = result.unwrap();
1063        assert_eq!(base_path, "path");
1064        assert_eq!(uri, "az://container/path");
1065    }
1066
1067    #[rstest]
1068    #[cfg(feature = "cloud")]
1069    fn test_create_object_store_from_path_gcs() {
1070        // Test GCS without service account (will use default credentials or fail gracefully)
1071        let mut options = AHashMap::new();
1072        options.insert("project_id".to_string(), "test-project".to_string());
1073
1074        let result = create_object_store_from_path("gs://test-bucket/path", Some(options));
1075        // GCS might fail due to missing credentials, but we're testing the path parsing
1076        // The function should at least parse the URI correctly before failing on auth
1077        match result {
1078            Ok((_, base_path, uri)) => {
1079                assert_eq!(base_path, "path");
1080                assert_eq!(uri, "gs://test-bucket/path");
1081            }
1082            Err(e) => {
1083                // Expected to fail due to missing credentials, but should contain bucket info
1084                let error_msg = format!("{e:?}");
1085                assert!(error_msg.contains("test-bucket") || error_msg.contains("credential"));
1086            }
1087        }
1088    }
1089
1090    #[rstest]
1091    #[cfg(feature = "cloud")]
1092    fn test_create_object_store_from_path_empty_options() {
1093        let result = create_object_store_from_path("s3://test-bucket/path", None);
1094        assert!(result.is_ok());
1095        let (_, base_path, uri) = result.unwrap();
1096        assert_eq!(base_path, "path");
1097        assert_eq!(uri, "s3://test-bucket/path");
1098    }
1099
1100    #[rstest]
1101    #[cfg(feature = "cloud")]
1102    fn test_parse_url_and_path() {
1103        let result = parse_url_and_path("s3://bucket/path/to/file");
1104        assert!(result.is_ok());
1105        let (url, path) = result.unwrap();
1106        assert_eq!(url.scheme(), "s3");
1107        assert_eq!(url.host_str().unwrap(), "bucket");
1108        assert_eq!(path, "path/to/file");
1109    }
1110
1111    #[rstest]
1112    #[cfg(feature = "cloud")]
1113    fn test_remote_store_root_url_preserves_authority() {
1114        let https_root = remote_store_root_url("https://example.com:9000/base/path").unwrap();
1115        assert_eq!(
1116            https_root.as_str().trim_end_matches('/'),
1117            "https://example.com:9000"
1118        );
1119
1120        let abfs_root =
1121            remote_store_root_url("abfs://container@account.dfs.core.windows.net/base/path")
1122                .unwrap();
1123        assert_eq!(
1124            abfs_root.as_str().trim_end_matches('/'),
1125            "abfs://container@account.dfs.core.windows.net"
1126        );
1127
1128        let full_uri = remote_full_uri(
1129            "https://example.com:9000/base/path",
1130            "base/path/data/%5E/file.parquet",
1131        )
1132        .unwrap();
1133        assert_eq!(
1134            full_uri,
1135            "https://example.com:9000/base/path/data/%5E/file.parquet"
1136        );
1137
1138        let location = create_object_store_location_from_path("s3://test-bucket/path", None)
1139            .expect("S3 location should be created");
1140        assert_eq!(location.base_path, "path");
1141        assert_eq!(
1142            location
1143                .store_root_url()
1144                .expect("S3 should be remote")
1145                .as_str()
1146                .trim_end_matches('/'),
1147            "s3://test-bucket"
1148        );
1149    }
1150
1151    #[rstest]
1152    #[cfg(feature = "cloud")]
1153    fn test_extract_host() {
1154        let url = url::Url::parse("s3://test-bucket/path").unwrap();
1155        let result = extract_host(&url, "Test error");
1156        assert!(result.is_ok());
1157        assert_eq!(result.unwrap(), "test-bucket");
1158    }
1159
1160    #[tokio::test]
1161    async fn test_min_max_from_parquet_metadata_rejects_negative_int64_statistics() {
1162        let temp_dir = tempfile::TempDir::new().unwrap();
1163        let object_store: Arc<dyn ObjectStore> = Arc::new(
1164            object_store::local::LocalFileSystem::new_with_prefix(temp_dir.path()).unwrap(),
1165        );
1166        let object_path = ObjectPath::from("negative_stats.parquet");
1167        let schema = Arc::new(Schema::new(vec![Field::new(
1168            "ts_init",
1169            DataType::Int64,
1170            false,
1171        )]));
1172        let batch = RecordBatch::try_new(
1173            schema,
1174            vec![Arc::new(Int64Array::from(vec![-2_i64, -1_i64]))],
1175        )
1176        .unwrap();
1177
1178        write_batches_to_object_store(
1179            &[batch],
1180            object_store.clone(),
1181            &object_path,
1182            None,
1183            None,
1184            None,
1185        )
1186        .await
1187        .unwrap();
1188
1189        let error =
1190            min_max_from_parquet_metadata_object_store(object_store, &object_path, "ts_init")
1191                .await
1192                .unwrap_err();
1193
1194        assert_eq!(
1195            error.to_string(),
1196            "Negative minimum value -2 for column 'ts_init'"
1197        );
1198    }
1199
1200    #[rstest]
1201    fn test_normalize_path_to_uri() {
1202        // Unix absolute paths
1203        assert_eq!(normalize_path_to_uri("/tmp/test"), "file:///tmp/test");
1204
1205        // Windows drive paths
1206        assert_eq!(
1207            normalize_path_to_uri("C:\\tmp\\test"),
1208            "file:///C:/tmp/test"
1209        );
1210        assert_eq!(normalize_path_to_uri("C:/tmp/test"), "file:///C:/tmp/test");
1211        assert_eq!(
1212            normalize_path_to_uri("D:\\data\\file.txt"),
1213            "file:///D:/data/file.txt"
1214        );
1215
1216        // Windows UNC paths
1217        assert_eq!(
1218            normalize_path_to_uri("\\\\server\\share\\file"),
1219            "file://server/share/file"
1220        );
1221
1222        // Already URIs - should remain unchanged
1223        assert_eq!(
1224            normalize_path_to_uri("s3://bucket/path"),
1225            "s3://bucket/path"
1226        );
1227        assert_eq!(
1228            normalize_path_to_uri("file:///tmp/test"),
1229            "file:///tmp/test"
1230        );
1231        assert_eq!(
1232            normalize_path_to_uri("https://example.com/path"),
1233            "https://example.com/path"
1234        );
1235    }
1236
1237    #[rstest]
1238    fn test_is_absolute_path() {
1239        // Unix absolute paths
1240        assert!(is_absolute_path("/tmp/test"));
1241        assert!(is_absolute_path("/"));
1242
1243        // Windows drive paths
1244        assert!(is_absolute_path("C:\\tmp\\test"));
1245        assert!(is_absolute_path("C:/tmp/test"));
1246        assert!(is_absolute_path("D:\\"));
1247        assert!(is_absolute_path("Z:/"));
1248
1249        // Windows UNC paths
1250        assert!(is_absolute_path("\\\\server\\share"));
1251        assert!(is_absolute_path("\\\\localhost\\c$"));
1252
1253        // Relative paths
1254        assert!(!is_absolute_path("tmp/test"));
1255        assert!(!is_absolute_path("./test"));
1256        assert!(!is_absolute_path("../test"));
1257        assert!(!is_absolute_path("test.txt"));
1258
1259        // Edge cases
1260        assert!(!is_absolute_path(""));
1261        assert!(!is_absolute_path("C"));
1262        assert!(!is_absolute_path("C:"));
1263        assert!(!is_absolute_path("\\"));
1264    }
1265
1266    #[rstest]
1267    fn test_path_to_file_uri() {
1268        // Unix absolute paths
1269        assert_eq!(path_to_file_uri("/tmp/test"), "file:///tmp/test");
1270        assert_eq!(path_to_file_uri("/"), "file:///");
1271
1272        // Windows drive paths
1273        assert_eq!(path_to_file_uri("C:\\tmp\\test"), "file:///C:/tmp/test");
1274        assert_eq!(path_to_file_uri("C:/tmp/test"), "file:///C:/tmp/test");
1275        assert_eq!(path_to_file_uri("D:\\"), "file:///D:/");
1276
1277        // Windows UNC paths
1278        assert_eq!(
1279            path_to_file_uri("\\\\server\\share\\file"),
1280            "file://server/share/file"
1281        );
1282        assert_eq!(
1283            path_to_file_uri("\\\\localhost\\c$\\test"),
1284            "file://localhost/c$/test"
1285        );
1286    }
1287}