1use 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
80pub 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
102pub 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
132pub 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
164pub 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 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 object_store.put(path, buffer.into()).await?;
198
199 Ok(())
200}
201
202fn 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
255pub 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 let (object_store, base_path, _) =
274 create_object_store_from_path(file_paths[0], storage_options)?;
275
276 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
305pub 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 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 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 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 let batches_to_write = if deduplicate.unwrap_or(false) {
359 deduplicate_record_batches(&all_batches)?
360 } else {
361 all_batches
362 };
363
364 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 for path in &file_paths {
377 if path != new_file_path {
378 object_store.delete(path).await?;
379 }
380 }
381
382 Ok(())
383}
384
385pub 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
405pub 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 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 for i in 0..metadata.num_row_groups() {
426 let row_group = metadata.row_group(i);
427
428 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 if let Statistics::Int64(int64_stats) = stats {
436 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 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 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
478pub 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#[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), }?;
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#[must_use]
592pub fn normalize_path_to_uri(path: &str) -> String {
593 if path.contains("://") {
594 path.to_string()
596 } else {
597 if is_absolute_path(path) {
599 path_to_file_uri(path)
600 } else {
601 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#[must_use]
611fn is_absolute_path(path: &str) -> bool {
612 if path.starts_with('/') {
613 true
615 } else if path.len() >= 3
616 && path.chars().nth(1) == Some(':')
617 && path.chars().nth(2) == Some('\\')
618 {
619 true
621 } else if path.len() >= 3
622 && path.chars().nth(1) == Some(':')
623 && path.chars().nth(2) == Some('/')
624 {
625 true
627 } else if path.starts_with("\\\\") {
628 true
630 } else {
631 false
632 }
633}
634
635#[must_use]
637fn path_to_file_uri(path: &str) -> String {
638 if path.starts_with('/') {
639 format!("file://{path}")
641 } else if path.len() >= 3 && path.chars().nth(1) == Some(':') {
642 let normalized = path.replace('\\', "/");
644 format!("file:///{normalized}")
645 } else if let Some(without_prefix) = path.strip_prefix("\\\\") {
646 let normalized = without_prefix.replace('\\', "/");
648 format!("file://{normalized}")
649 } else {
650 format!("file://{path}")
652 }
653}
654
655#[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 let without_leading = without_scheme.trim_start_matches('/');
665 without_leading.replace('/', "\\")
666}
667
668#[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
674pub(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
703pub(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
714fn 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#[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 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 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#[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 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 log::warn!(
800 "project_id should be set via service account or environment variables"
801 );
802 }
803 "application_credentials" => {
804 unsafe {
809 std::env::set_var("GOOGLE_APPLICATION_CREDENTIALS", &value);
810 }
811 }
812 _ => {
813 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#[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 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 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 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#[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 let account = host
894 .split('.')
895 .next()
896 .ok_or_else(|| anyhow::anyhow!("Invalid ABFS URI: cannot extract account from host"))?;
897
898 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 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 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 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#[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 if let Some(options) = storage_options {
970 for (key, _value) in options {
971 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#[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#[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 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 assert_eq!(uri, format!("file://{}", temp_dir.to_str().unwrap()));
1025
1026 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 options.insert("account_key".to_string(), "dGVzdGtleQ==".to_string()); 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 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 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 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 assert_eq!(normalize_path_to_uri("/tmp/test"), "file:///tmp/test");
1204
1205 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 assert_eq!(
1218 normalize_path_to_uri("\\\\server\\share\\file"),
1219 "file://server/share/file"
1220 );
1221
1222 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 assert!(is_absolute_path("/tmp/test"));
1241 assert!(is_absolute_path("/"));
1242
1243 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 assert!(is_absolute_path("\\\\server\\share"));
1251 assert!(is_absolute_path("\\\\localhost\\c$"));
1252
1253 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 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 assert_eq!(path_to_file_uri("/tmp/test"), "file:///tmp/test");
1270 assert_eq!(path_to_file_uri("/"), "file:///");
1271
1272 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 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}