Skip to main content

delta_kernel/
utils.rs

1//! Various utility functions/macros used throughout the kernel
2use std::borrow::Cow;
3use std::ops::Deref;
4use std::path::PathBuf;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use delta_kernel_derive::internal_api;
8use url::Url;
9
10use crate::{DeltaResult, Error};
11
12/// convenient way to return an error if a condition isn't true
13macro_rules! require {
14    ( $cond:expr, $err:expr ) => {
15        if !($cond) {
16            return Err($err);
17        }
18    };
19}
20
21pub(crate) use require;
22
23/// Dual of the `FromIterator` trait, similar to how `Into` is the dual of `From`. It is
24/// automatically implemented for any iterable whose items collect into `T`, and can drastically
25/// simplify type bounds. For example, `CollectInto` allows to write this:
26///
27/// ```
28/// # use delta_kernel::CollectInto;
29/// # struct Foo;
30/// fn foo(arg: impl CollectInto<Foo>) -> Foo {
31///     arg.collect_into()
32/// }
33/// ```
34///
35/// instead of the much more verbose:
36///
37/// ```
38/// # struct Foo;
39/// fn foo<T>(arg: impl IntoIterator<Item = T>) -> Foo
40/// where
41///     Foo: FromIterator<T>,
42/// {
43///     Foo::from_iter(arg)
44/// }
45/// ```
46pub trait CollectInto<T>: IntoIterator + Sized {
47    /// Collects this iterable into a `T`
48    fn collect_into(self) -> T;
49}
50
51// blanket impl
52impl<I: IntoIterator, T: FromIterator<I::Item>> CollectInto<T> for I {
53    fn collect_into(self) -> T {
54        T::from_iter(self)
55    }
56}
57
58/// Try to parse string uri into a URL for a table path. This will do it's best to handle things
59/// like `/local/paths`, and even `../relative/paths`.
60#[allow(unused)]
61#[internal_api]
62pub(crate) fn try_parse_uri(uri: impl AsRef<str>) -> DeltaResult<Url> {
63    let uri = uri.as_ref();
64    let uri_type = resolve_uri_type(uri)?;
65    let url = match uri_type {
66        UriType::LocalPath(path) => {
67            if !path.exists() {
68                // When we support writes, create a directory if we can
69                return Err(Error::InvalidTableLocation(format!(
70                    "Path does not exist: {path:?}"
71                )));
72            }
73            if !path.is_dir() {
74                return Err(Error::InvalidTableLocation(format!(
75                    "{path:?} is not a directory"
76                )));
77            }
78            let path = std::fs::canonicalize(path).map_err(|err| {
79                let msg = format!("Invalid table location: {uri} Error: {err:?}");
80                Error::InvalidTableLocation(msg)
81            })?;
82            Url::from_directory_path(path.clone()).map_err(|_| {
83                let msg = format!(
84                    "Could not construct a URL from canonicalized path: {path:?}.\n\
85                     Something must be very wrong with the table path."
86                );
87                Error::InvalidTableLocation(msg)
88            })?
89        }
90        UriType::Url(url) => url,
91    };
92    Ok(url)
93}
94
95#[allow(unused)]
96#[derive(Debug)]
97enum UriType {
98    LocalPath(PathBuf),
99    Url(Url),
100}
101
102/// Utility function to figure out whether string representation of the path is either local path or
103/// some kind or URL.
104///
105/// Will return an error if the path is not valid.
106#[allow(unused)]
107fn resolve_uri_type(table_uri: impl AsRef<str>) -> DeltaResult<UriType> {
108    let table_uri = table_uri.as_ref();
109    let table_uri = if table_uri.ends_with('/') {
110        Cow::Borrowed(table_uri)
111    } else {
112        Cow::Owned(format!("{table_uri}/"))
113    };
114    if let Ok(url) = Url::parse(&table_uri) {
115        let scheme = url.scheme().to_string();
116        if url.scheme() == "file" {
117            Ok(UriType::LocalPath(
118                url.to_file_path()
119                    .map_err(|_| Error::invalid_table_location(table_uri))?,
120            ))
121        } else if scheme.len() == 1 {
122            // NOTE this check is required to support absolute windows paths which may properly
123            // parse as url we assume here that a single character scheme is a windows drive letter
124            Ok(UriType::LocalPath(PathBuf::from(table_uri.as_ref())))
125        } else {
126            Ok(UriType::Url(url))
127        }
128    } else {
129        Ok(UriType::LocalPath(table_uri.deref().into()))
130    }
131}
132
133/// Returns the current time as a Duration since Unix epoch.
134pub(crate) fn current_time_duration() -> DeltaResult<Duration> {
135    SystemTime::now()
136        .duration_since(UNIX_EPOCH)
137        .map_err(|e| Error::generic(format!("System time before Unix epoch: {e}")))
138}
139
140/// Returns the current time in milliseconds since Unix epoch.
141pub(crate) fn current_time_ms() -> DeltaResult<i64> {
142    let duration = current_time_duration()?;
143    i64::try_from(duration.as_millis())
144        .map_err(|_| Error::generic("Current timestamp exceeds i64 millisecond range"))
145}
146
147/// Extension trait for folding zero or one value from an [`Option`] into a base value.
148#[internal_api]
149pub(crate) trait FoldWithOption: Sized {
150    /// Applies an optional fold operation `f` to `self` if `opt` is [`Some`]; otherwise returns
151    /// `self`.
152    ///
153    /// Similar to `opt.iter().fold(self, |acc, value| f(acc, value))`, but accepting `FnOnce`
154    /// instead of requiring `FnMut`, and with the base value as receiver instead of the option.
155    fn fold_with<U>(self, opt: Option<U>, f: impl FnOnce(Self, U) -> Self) -> Self;
156}
157
158impl<T: Sized> FoldWithOption for T {
159    fn fold_with<U>(self, opt: Option<U>, f: impl FnOnce(Self, U) -> Self) -> Self {
160        match opt {
161            Some(value) => f(self, value),
162            None => self,
163        }
164    }
165}
166
167/// Extension trait for adding completion callbacks to iterators.
168pub(crate) trait IteratorExt: Iterator + Sized {
169    /// Wraps this iterator to call a closure when fully exhausted.
170    ///
171    /// The closure is called only when `next()` returns `None`. If the iterator
172    /// is dropped before exhaustion, a warning is logged but the closure is not called.
173    fn on_complete<F: FnOnce()>(self, f: F) -> OnComplete<Self, F> {
174        OnComplete {
175            inner: self,
176            on_complete: Some(f),
177        }
178    }
179}
180
181impl<I: Iterator> IteratorExt for I {}
182
183/// Iterator adaptor that executes a closure when fully exhausted.
184pub(crate) struct OnComplete<I, F: FnOnce()> {
185    inner: I,
186    on_complete: Option<F>,
187}
188
189impl<I, F: FnOnce()> Drop for OnComplete<I, F> {
190    fn drop(&mut self) {
191        if self.on_complete.is_some() {
192            tracing::debug!(
193                "OnComplete iterator dropped before exhaustion; completion callback not called"
194            );
195        }
196    }
197}
198
199impl<I, F> Iterator for OnComplete<I, F>
200where
201    I: Iterator,
202    F: FnOnce(),
203{
204    type Item = I::Item;
205
206    fn size_hint(&self) -> (usize, Option<usize>) {
207        self.inner.size_hint()
208    }
209
210    fn next(&mut self) -> Option<Self::Item> {
211        match self.inner.next() {
212            Some(item) => Some(item),
213            None => {
214                if let Some(f) = self.on_complete.take() {
215                    f();
216                }
217                None
218            }
219        }
220    }
221}
222
223#[cfg(test)]
224pub(crate) mod test_utils {
225    use std::path::{Path, PathBuf};
226    use std::sync::{Arc, Mutex};
227
228    use itertools::Itertools;
229    use serde::Serialize;
230    use tempfile::TempDir;
231    use test_utils::{delta_path_for_version, load_test_data};
232    use tracing::subscriber::DefaultGuard;
233    use tracing_subscriber::util::SubscriberInitExt as _;
234    use url::Url;
235
236    use crate::actions::{
237        get_all_actions_schema, Add, Cdc, CommitInfo, Metadata, Protocol, Remove,
238    };
239    use crate::arrow::array::{RecordBatch, StringArray, StructArray};
240    use crate::arrow::datatypes::{DataType, Field, Schema as ArrowSchema};
241    use crate::committer::FileSystemCommitter;
242    use crate::engine::arrow_conversion::{parquet_field_id_metadata, TryIntoArrow as _};
243    use crate::engine::arrow_data::ArrowEngineData;
244    use crate::engine::sync::SyncEngine;
245    use crate::metrics::{MetricEvent, MetricsReporter, WithMetricsReporterLayer as _};
246    use crate::object_store::local::LocalFileSystem;
247    use crate::object_store::memory::InMemory;
248    use crate::object_store::ObjectStoreExt as _;
249    use crate::parquet::arrow::PARQUET_FIELD_ID_META_KEY;
250    use crate::path::ParsedLogPath;
251    use crate::table_features::ColumnMappingMode;
252    use crate::transaction::create_table::create_table;
253    use crate::transaction::{CreateTable, Transaction};
254    use crate::{DeltaResult, Engine, EngineData, Error, FileMeta, Snapshot, SnapshotRef};
255
256    /// Parses `path` (a full URL string) into a [`ParsedLogPath`] with zero size, for building
257    /// synthetic log-file listings in tests.
258    pub(crate) fn create_log_path(path: &str) -> ParsedLogPath<FileMeta> {
259        create_log_path_with_size(path, 0)
260    }
261
262    /// [`create_log_path`] with an explicit file size.
263    pub(crate) fn create_log_path_with_size(path: &str, size: u64) -> ParsedLogPath<FileMeta> {
264        ParsedLogPath::try_from(FileMeta {
265            location: Url::parse(path).expect("Invalid file URL"),
266            last_modified: 0,
267            size,
268        })
269        .unwrap()
270        .unwrap()
271    }
272
273    /// A metrics reporter that captures all events for test assertions.
274    #[derive(Debug, Default)]
275    pub(crate) struct CapturingReporter {
276        events: Mutex<Vec<MetricEvent>>,
277    }
278
279    impl MetricsReporter for CapturingReporter {
280        fn report(&self, event: MetricEvent) {
281            self.events.lock().unwrap().push(event);
282        }
283    }
284
285    impl CapturingReporter {
286        /// Returns a copy of all captured events.
287        pub(crate) fn events(&self) -> Vec<MetricEvent> {
288            self.events.lock().unwrap().clone()
289        }
290    }
291
292    /// Kernel-internal twin of [`test_utils::install_thread_local_metrics_reporter`].
293    ///
294    /// Internal tests need their own helper because the trait identity of `MetricsReporter`
295    /// differs across the test_utils <-> kernel path-dep boundary. Both helpers wrap
296    /// [`test_utils::ensure_metrics_compatible_global_subscriber`] + a thread-local
297    /// `set_default` and serve the same purpose: install a metrics-collecting subscriber
298    /// in a way that is robust against tracing callsite-cache poisoning.
299    pub(crate) fn install_thread_local_metrics_reporter(
300        reporter: Arc<dyn MetricsReporter>,
301    ) -> DefaultGuard {
302        test_utils::ensure_metrics_compatible_global_subscriber();
303        tracing_subscriber::registry()
304            .with_metrics_reporter_layer(reporter)
305            .set_default()
306    }
307
308    #[derive(Serialize)]
309    pub(crate) enum Action {
310        #[serde(rename = "add")]
311        Add(Add),
312        #[serde(rename = "remove")]
313        Remove(Remove),
314        #[serde(rename = "cdc")]
315        Cdc(Cdc),
316        #[serde(rename = "metaData")]
317        Metadata(Metadata),
318        #[serde(rename = "protocol")]
319        Protocol(Protocol),
320        #[allow(unused)]
321        #[serde(rename = "commitInfo")]
322        CommitInfo(CommitInfo),
323    }
324
325    use crate::schema::{
326        schema, schema_ref, ArrayType, ColumnMetadataKey, DataType as KernelDataType, MapType,
327        MetadataValue, SchemaRef, StructField, StructType,
328    };
329
330    /// A mock table that writes commits to a local temporary delta log. This can be used to
331    /// construct a delta log used for testing.
332    pub(crate) struct LocalMockTable {
333        commit_num: u64,
334        store: Arc<LocalFileSystem>,
335        dir: TempDir,
336    }
337
338    impl LocalMockTable {
339        pub(crate) fn new() -> Self {
340            let dir = tempfile::tempdir().unwrap();
341            let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path()).unwrap());
342            Self {
343                commit_num: 0,
344                store,
345                dir,
346            }
347        }
348        /// Writes all `actions` to a new commit in the log
349        pub(crate) async fn commit(&mut self, actions: impl IntoIterator<Item = Action>) {
350            let data = actions
351                .into_iter()
352                .map(|action| serde_json::to_string(&action).unwrap())
353                .join("\n");
354
355            let path = delta_path_for_version(self.commit_num, "json");
356            self.commit_num += 1;
357
358            self.store
359                .put(&path, data.into())
360                .await
361                .expect("put log file in store");
362        }
363
364        /// Get the path to the root of the table.
365        pub(crate) fn table_root(&self) -> &Path {
366            self.dir.path()
367        }
368    }
369
370    /// Try to convert an `EngineData` into a `RecordBatch`. Panics if not using `ArrowEngineData`
371    /// from the default module
372    fn into_record_batch(engine_data: Box<dyn EngineData>) -> RecordBatch {
373        ArrowEngineData::try_from_engine_data(engine_data)
374            .unwrap()
375            .into()
376    }
377
378    /// Checks that two `EngineData` objects are equal by converting them to `RecordBatch` and
379    /// comparing
380    pub(crate) fn assert_batch_matches(actual: Box<dyn EngineData>, expected: Box<dyn EngineData>) {
381        assert_eq!(into_record_batch(actual), into_record_batch(expected));
382    }
383
384    pub(crate) fn string_array_to_engine_data(string_array: StringArray) -> Box<dyn EngineData> {
385        let string_field = Arc::new(Field::new("a", DataType::Utf8, true));
386        let schema = Arc::new(ArrowSchema::new(vec![string_field]));
387        let batch = RecordBatch::try_new(schema, vec![Arc::new(string_array)])
388            .expect("Can't convert to record batch");
389        Box::new(ArrowEngineData::new(batch))
390    }
391
392    pub(crate) fn parse_json_batch(json_strings: StringArray) -> Box<dyn EngineData> {
393        let engine = SyncEngine::new();
394        let json_handler = engine.json_handler();
395        let output_schema = get_all_actions_schema().clone();
396        json_handler
397            .parse_json(string_array_to_engine_data(json_strings), output_schema)
398            .unwrap()
399    }
400
401    pub(crate) fn action_batch() -> Box<dyn EngineData> {
402        let json_strings: StringArray = vec![
403            r#"{"add":{"path":"part-00000-fae5310a-a37d-4e51-827b-c3d5516560ca-c000.snappy.parquet","partitionValues":{},"size":635,"modificationTime":1677811178336,"dataChange":true,"stats":"{\"numRecords\":10,\"minValues\":{\"value\":0},\"maxValues\":{\"value\":9},\"nullCount\":{\"value\":0},\"tightBounds\":true}","tags":{"INSERTION_TIME":"1677811178336000","MIN_INSERTION_TIME":"1677811178336000","MAX_INSERTION_TIME":"1677811178336000","OPTIMIZE_TARGET_SIZE":"268435456"}}}"#,
404            r#"{"remove":{"path":"part-00003-f525f459-34f9-46f5-82d6-d42121d883fd.c000.snappy.parquet","deletionTimestamp":1670892998135,"dataChange":true,"partitionValues":{"c1":"4","c2":"c"},"size":452}}"#,
405            r#"{"commitInfo":{"timestamp":1677811178585,"operation":"WRITE","operationParameters":{"mode":"ErrorIfExists","partitionBy":"[]"},"isolationLevel":"WriteSerializable","isBlindAppend":true,"operationMetrics":{"numFiles":"1","numOutputRows":"10","numOutputBytes":"635"},"engineInfo":"Databricks-Runtime/<unknown>","txnId":"a6a94671-55ef-450e-9546-b8465b9147de"}}"#,
406            r#"{"protocol":{"minReaderVersion":3,"minWriterVersion":7,"readerFeatures":["deletionVectors"],"writerFeatures":["deletionVectors"]}}"#,
407            r#"{"metaData":{"id":"testId","format":{"provider":"parquet","options":{}},"schemaString":"{\"type\":\"struct\",\"fields\":[{\"name\":\"value\",\"type\":\"integer\",\"nullable\":true,\"metadata\":{}}]}","partitionColumns":[],"configuration":{"delta.enableDeletionVectors":"true","delta.columnMapping.mode":"none", "delta.enableChangeDataFeed":"true"},"createdTime":1677811175819}}"#,
408            r#"{"cdc":{"path":"_change_data/age=21/cdc-00000-93f7fceb-281a-446a-b221-07b88132d203.c000.snappy.parquet","partitionValues":{"age":"21"},"size":1033,"dataChange":false}}"#,
409            r#"{"sidecar":{"path":"016ae953-37a9-438e-8683-9a9a4a79a395.parquet","sizeInBytes":9268,"modificationTime":1714496113961,"tags":{"tag_foo":"tag_bar"}}}"#,
410            r#"{"txn":{"appId":"myApp","version": 3}}"#,
411            r#"{"checkpointMetadata":{"version":2, "tags":{"tag_foo":"tag_bar"}}}"#,
412        ]
413        .into();
414        parse_json_batch(json_strings)
415    }
416
417    // TODO: allow tests to pass in context (issue#1133)
418    #[track_caller]
419    pub(crate) fn assert_result_error_with_message<T, E: ToString>(
420        res: Result<T, E>,
421        message: &str,
422    ) {
423        match res {
424            Ok(_) => panic!("Expected error with message {message}, but got Ok result"),
425            Err(error) => {
426                let error_str = error.to_string();
427                assert!(
428                    error_str.contains(message),
429                    "Error message does not contain the expected message.\nExpected message:\t{message}\nActual message:\t\t{error_str}"
430                );
431            }
432        }
433    }
434
435    /// Asserts the 2x2 matrix of (schema_has_feature, protocol_supports_feature) outcomes
436    /// for schema-level feature validators. The expected pattern is:
437    /// - schema + protocol => Ok
438    /// - no schema + no protocol => Ok
439    /// - no schema + protocol => Ok
440    /// - schema + no protocol => Err (orphaned schema presence)
441    ///
442    /// Additional error schemas (e.g. nested) are also tested against `protocol_without`.
443    #[track_caller]
444    pub(crate) fn assert_schema_feature_validation(
445        schema_with: &StructType,
446        schema_without: &StructType,
447        protocol_with: &Protocol,
448        protocol_without: &Protocol,
449        extra_err_schemas: &[&StructType],
450        err_msg: &str,
451    ) {
452        make_test_tc(schema_with.clone(), protocol_with.clone(), [])
453            .expect("feature present + supported");
454        make_test_tc(schema_without.clone(), protocol_without.clone(), [])
455            .expect("feature absent + unsupported");
456        make_test_tc(schema_without.clone(), protocol_with.clone(), [])
457            .expect("feature absent + supported");
458        assert_result_error_with_message(
459            make_test_tc(schema_with.clone(), protocol_without.clone(), []),
460            err_msg,
461        );
462        for schema in extra_err_schemas {
463            assert_result_error_with_message(
464                make_test_tc((*schema).clone(), protocol_without.clone(), []),
465                err_msg,
466            );
467        }
468    }
469
470    /// Creates a [`TableConfiguration`] from a schema, protocol, and table properties.
471    /// Useful for testing validators that need a TC.
472    pub(crate) fn make_test_tc(
473        schema: StructType,
474        protocol: Protocol,
475        props: impl IntoIterator<Item = (String, String)>,
476    ) -> crate::DeltaResult<crate::table_configuration::TableConfiguration> {
477        let schema = std::sync::Arc::new(schema);
478        let metadata =
479            Metadata::try_new(None, None, schema, vec![], 0, props.into_iter().collect()).unwrap();
480        let table_root = Url::try_from("file:///").unwrap();
481        crate::table_configuration::TableConfiguration::try_new(metadata, protocol, table_root, 0)
482    }
483
484    // ==================== Test schema helpers ====================
485    //
486    // Reusable test schemas
487    // Each variant exists with and without column mapping metadata.
488
489    /// Builds a nullable [`StructField`] carrying column-mapping id + physical name metadata.
490    fn cm_field(
491        name: &str,
492        id: i64,
493        physical_name: &str,
494        ty: impl Into<KernelDataType>,
495    ) -> StructField {
496        StructField::nullable(name, ty).with_metadata([
497            (
498                ColumnMetadataKey::ColumnMappingId.as_ref(),
499                MetadataValue::Number(id),
500            ),
501            (
502                ColumnMetadataKey::ColumnMappingPhysicalName.as_ref(),
503                MetadataValue::String(physical_name.into()),
504            ),
505        ])
506    }
507
508    /// Shared fixture for nested field-id propagation tests.
509    pub(crate) struct NestedFieldIdFixture {
510        pub(crate) kernel_schema: StructType,
511        pub(crate) input_arrow_data: StructArray,
512        pub(crate) expected_arrow_schema: ArrowSchema,
513    }
514
515    /// Recursively collect `(field_name, metadata_value)` pairs for the given metadata key
516    /// across all (nested) Arrow fields in `schema`.
517    pub(crate) fn collect_arrow_field_metadata(
518        schema: &ArrowSchema,
519        metadata_key: &str,
520    ) -> Vec<(String, String)> {
521        fn collect_from_fields(
522            fields: &[Arc<Field>],
523            metadata_key: &str,
524            out: &mut Vec<(String, String)>,
525        ) {
526            for field in fields {
527                collect_from_field(field, metadata_key, out);
528            }
529        }
530
531        fn collect_from_field(field: &Field, metadata_key: &str, out: &mut Vec<(String, String)>) {
532            if let Some(value) = field.metadata().get(metadata_key) {
533                out.push((field.name().clone(), value.clone()));
534            }
535
536            match field.data_type() {
537                DataType::Struct(fields) => collect_from_fields(fields, metadata_key, out),
538                DataType::List(entry) | DataType::Map(entry, _) => {
539                    collect_from_field(entry, metadata_key, out)
540                }
541                _ => {}
542            }
543        }
544
545        let mut out = Vec::new();
546        collect_from_fields(schema.fields(), metadata_key, &mut out);
547        out
548    }
549
550    /// Build the kernel schema for `array_in_map: map<int, array<int>>` with caller-provided
551    /// top-level field metadata.
552    pub(crate) fn array_in_map_kernel_schema(
553        metadata: impl IntoIterator<Item = (String, MetadataValue)>,
554    ) -> StructType {
555        let array_in_map = StructField::nullable(
556            "array_in_map",
557            MapType::new(
558                KernelDataType::INTEGER,
559                ArrayType::new(KernelDataType::INTEGER, true),
560                true,
561            ),
562        )
563        .with_metadata(metadata);
564        StructType::try_new(vec![array_in_map]).unwrap()
565    }
566
567    /// Build an [`array_in_map_kernel_schema`] with `parquet.field.id` on the top-level field
568    /// and a nested-ids JSON map (key/value/element) under `nested_ids_meta_key`.
569    pub(crate) fn array_in_map_with_field_ids(nested_ids_meta_key: &str) -> StructType {
570        let nested_ids = MetadataValue::Other(test_utils::nested_ids_json(&[
571            ("array_in_map.key", 100),
572            ("array_in_map.value", 101),
573            ("array_in_map.value.element", 102),
574        ]));
575        array_in_map_kernel_schema([
576            (
577                ColumnMetadataKey::ParquetFieldId.as_ref().to_string(),
578                MetadataValue::from(1i64),
579            ),
580            (nested_ids_meta_key.to_string(), nested_ids),
581        ])
582    }
583
584    /// Build an empty Arrow `StructArray` matching [`array_in_map_kernel_schema`] with no field-id
585    /// metadata.
586    pub(crate) fn array_in_map_arrow_data_without_field_ids() -> StructArray {
587        let kernel_schema =
588            array_in_map_kernel_schema(std::iter::empty::<(String, MetadataValue)>());
589        let arrow_schema: ArrowSchema = (&kernel_schema).try_into_arrow().unwrap();
590        let batch = RecordBatch::new_empty(Arc::new(arrow_schema));
591        StructArray::try_new(
592            batch.schema().fields.clone(),
593            batch.columns().to_vec(),
594            None,
595        )
596        .unwrap()
597    }
598
599    /// Build a [`NestedFieldIdFixture`] covering Array+Map nested-id propagation through a
600    /// Struct boundary.
601    ///
602    /// ## 1. Kernel schema
603    ///
604    /// Two [`StructField`]s `top` and `inner` carry `parquet.field.id` (rewritten to
605    /// `PARQUET:field_id` in the Arrow output) plus a `<nested_ids_meta_key>` JSON map rooted
606    /// at each field's name. Each carries an array inside a map.
607    ///
608    /// ```json
609    /// {
610    ///   "type": "struct",
611    ///   "fields": [{
612    ///     "name": "top",
613    ///     "type": {
614    ///       "type": "map",
615    ///       "keyType":   {"type": "array", "elementType": "integer"},
616    ///       "valueType": {"type": "struct", "fields": [{
617    ///         "name": "inner",
618    ///         "type": {"type": "map", "keyType": "integer",
619    ///                  "valueType": {"type": "array", "elementType": "integer"}},
620    ///         "metadata": {
621    ///           "parquet.field.id": 2,
622    ///           "<nested_ids_meta_key>": {
623    ///             "inner.key": 200, "inner.value": 201, "inner.value.element": 202
624    ///           }
625    ///         }
626    ///       }]}
627    ///     },
628    ///     "metadata": {
629    ///       "parquet.field.id": 1,
630    ///       "<nested_ids_meta_key>": {
631    ///         "top.key": 100, "top.key.element": 101, "top.value": 102
632    ///       }
633    ///     }
634    ///   }]
635    /// }
636    /// ```
637    ///
638    /// ## 2. Input Arrow schema
639    ///
640    /// Same shape as kernel schema with no metadata anywhere, except a *stale*
641    /// `PARQUET:field_id=999` on the synthesized `top.key.element` field.
642    ///
643    /// ## 3. Expected output Arrow schema
644    ///
645    /// What `try_into_arrow(kernel schema)` and
646    /// `apply_schema(input arrow schema, kernel schema)` should both produce:
647    /// - `top` and `inner` carry `PARQUET:field_id` (rewritten from `parquet.field.id`).
648    /// - Synthesized list/map `key`/`value`/`element` fields each carry `PARQUET:field_id` pulled
649    ///   from the corresponding nested-ids JSON entry. `top.key.element` has `101` (kernel's), not
650    ///   the stale `999` from the input.
651    ///
652    /// ```json
653    /// {
654    ///   "fields": [{
655    ///     "name": "top", "type": "map",
656    ///     "metadata": {"PARQUET:field_id": "1"},
657    ///     "entries": { "name": "key_value", "type": "struct", "fields": [
658    ///       {
659    ///         "name": "key", "type": "list",
660    ///         "metadata": {"PARQUET:field_id": "100"},
661    ///         "element": {
662    ///           "name": "element", "type": "int32",
663    ///           "metadata": {"PARQUET:field_id": "101"}
664    ///         }
665    ///       },
666    ///       {
667    ///         "name": "value", "type": "struct",
668    ///         "metadata": {"PARQUET:field_id": "102"},
669    ///         "fields": [{
670    ///           "name": "inner", "type": "map",
671    ///           "metadata": {"PARQUET:field_id": "2"},
672    ///           "entries": { "name": "key_value", "type": "struct", "fields": [
673    ///             {
674    ///               "name": "key", "type": "int32",
675    ///               "metadata": {"PARQUET:field_id": "200"}
676    ///             },
677    ///             {
678    ///               "name": "value", "type": "list",
679    ///               "metadata": {"PARQUET:field_id": "201"},
680    ///               "element": {
681    ///                 "name": "element", "type": "int32",
682    ///                 "metadata": {"PARQUET:field_id": "202"}
683    ///               }
684    ///             }
685    ///           ]}
686    ///         }]
687    ///       }
688    ///     ]}
689    ///   }]
690    /// }
691    /// ```
692    pub(crate) fn complex_nested_with_field_ids(nested_ids_meta_key: &str) -> NestedFieldIdFixture {
693        NestedFieldIdFixture {
694            kernel_schema: build_complex_nested_kernel_schema(nested_ids_meta_key),
695            input_arrow_data: build_arrow_input_with_stale_element_id(),
696            expected_arrow_schema: expected_complex_nested_arrow_schema(),
697        }
698    }
699
700    /// Build the input Arrow data for [`complex_nested_with_field_ids`] by
701    /// striping the metadata from [`build_complex_nested_kernel_schema`], and
702    /// add one stale `PARQUET:field_id` to the `top.key.element` field.
703    fn build_arrow_input_with_stale_element_id() -> StructArray {
704        // Get the no-meta Arrow shape from the kernel schema.
705        let plain_inner = StructField::nullable("inner", complex_nested_inner_map_type());
706        let plain_top = StructField::nullable(
707            "top",
708            complex_nested_outer_map_type(StructType::try_new(vec![plain_inner]).unwrap()),
709        );
710        let plain_kernel_schema = StructType::try_new(vec![plain_top]).unwrap();
711        let plain_arrow_schema: ArrowSchema = (&plain_kernel_schema).try_into_arrow().unwrap();
712
713        // Add stale `PARQUET:field_id` to the `top.key.element` field.
714        let top = plain_arrow_schema.field(0);
715        let DataType::Map(entries, sorted) = top.data_type() else {
716            unreachable!("top is a Map by construction");
717        };
718        let DataType::Struct(entries_fields) = entries.data_type() else {
719            unreachable!("map entries is a Struct by construction");
720        };
721        let outer_key = &entries_fields[0];
722        let DataType::List(outer_element) = outer_key.data_type() else {
723            unreachable!("outer map key is a List by construction");
724        };
725        let stale_element = outer_element
726            .as_ref()
727            .clone()
728            .with_metadata([(PARQUET_FIELD_ID_META_KEY.to_string(), "999".to_string())].into());
729        let new_outer_key = Field::new(
730            outer_key.name(),
731            DataType::List(Arc::new(stale_element)),
732            outer_key.is_nullable(),
733        );
734        let new_entries = Field::new(
735            entries.name(),
736            DataType::Struct(vec![new_outer_key, entries_fields[1].as_ref().clone()].into()),
737            entries.is_nullable(),
738        );
739        let new_top = Field::new(
740            top.name(),
741            DataType::Map(Arc::new(new_entries), *sorted),
742            top.is_nullable(),
743        );
744        let arrow_input_schema = ArrowSchema::new(vec![new_top]);
745        let batch = RecordBatch::new_empty(Arc::new(arrow_input_schema));
746        StructArray::try_new(
747            batch.schema().fields.clone(),
748            batch.columns().to_vec(),
749            None,
750        )
751        .unwrap()
752    }
753
754    fn complex_nested_inner_map_type() -> KernelDataType {
755        KernelDataType::from(MapType::new(
756            KernelDataType::INTEGER,
757            ArrayType::new(KernelDataType::INTEGER, true),
758            true,
759        ))
760    }
761
762    fn complex_nested_outer_map_type(struct_value: StructType) -> KernelDataType {
763        KernelDataType::from(MapType::new(
764            ArrayType::new(KernelDataType::INTEGER, true),
765            struct_value,
766            true,
767        ))
768    }
769
770    /// Build the kernel schema described by [`complex_nested_with_field_ids`].
771    pub(crate) fn build_complex_nested_kernel_schema(nested_ids_meta_key: &str) -> StructType {
772        let top_nested_ids = test_utils::nested_ids_json(&[
773            ("top.key", 100),
774            ("top.key.element", 101),
775            ("top.value", 102),
776        ]);
777        let inner_nested_ids = test_utils::nested_ids_json(&[
778            ("inner.key", 200),
779            ("inner.value", 201),
780            ("inner.value.element", 202),
781        ]);
782        // Each `StructField` carries `parquet.field.id` plus the nested-ids JSON.
783        let inner_field = StructField::nullable("inner", complex_nested_inner_map_type())
784            .with_metadata([
785                (
786                    ColumnMetadataKey::ParquetFieldId.as_ref().to_string(),
787                    MetadataValue::from(2i64),
788                ),
789                (
790                    nested_ids_meta_key.to_string(),
791                    MetadataValue::Other(inner_nested_ids),
792                ),
793            ]);
794        let top_field = StructField::nullable(
795            "top",
796            complex_nested_outer_map_type(StructType::try_new(vec![inner_field]).unwrap()),
797        )
798        .with_metadata([
799            (
800                ColumnMetadataKey::ParquetFieldId.as_ref().to_string(),
801                MetadataValue::from(1i64),
802            ),
803            (
804                nested_ids_meta_key.to_string(),
805                MetadataValue::Other(top_nested_ids),
806            ),
807        ]);
808        StructType::try_new(vec![top_field]).unwrap()
809    }
810
811    /// Build the expected output Arrow schema for [`complex_nested_with_field_ids`].
812    fn expected_complex_nested_arrow_schema() -> ArrowSchema {
813        // top.value.inner.value.element: int (PARQUET:field_id=202).
814        let inner_list_element = Field::new("element", DataType::Int32, true)
815            .with_metadata(parquet_field_id_metadata(Some(202)));
816        // top.value.inner.value: list<int> (PARQUET:field_id=201).
817        let inner_value = Field::new("value", DataType::List(Arc::new(inner_list_element)), true)
818            .with_metadata(parquet_field_id_metadata(Some(201)));
819        // top.value.inner.key: int (PARQUET:field_id=200).
820        let inner_key = Field::new("key", DataType::Int32, false)
821            .with_metadata(parquet_field_id_metadata(Some(200)));
822        // top.value.inner.key_value: synthesized map-entries struct (no field id).
823        let inner_entries = Field::new(
824            "key_value",
825            DataType::Struct(vec![inner_key, inner_value].into()),
826            false,
827        );
828        // top.value.inner: map<int, list<int>> (PARQUET:field_id=2).
829        let inner_field = Field::new("inner", DataType::Map(Arc::new(inner_entries), false), true)
830            .with_metadata(parquet_field_id_metadata(Some(2)));
831        // top.value: struct<inner: ...> (PARQUET:field_id=102).
832        let struct_value_field =
833            Field::new("value", DataType::Struct(vec![inner_field].into()), true)
834                .with_metadata(parquet_field_id_metadata(Some(102)));
835        // top.key.element: int (PARQUET:field_id=101).
836        let outer_key_element = Field::new("element", DataType::Int32, true)
837            .with_metadata(parquet_field_id_metadata(Some(101)));
838        // top.key: list<int> (PARQUET:field_id=100).
839        let outer_key = Field::new("key", DataType::List(Arc::new(outer_key_element)), false)
840            .with_metadata(parquet_field_id_metadata(Some(100)));
841        // top.key_value: synthesized map-entries struct (no field id).
842        let outer_entries = Field::new(
843            "key_value",
844            DataType::Struct(vec![outer_key, struct_value_field].into()),
845            false,
846        );
847        // top: map<list<int>, struct<...>> (PARQUET:field_id=1).
848        let top_field = Field::new("top", DataType::Map(Arc::new(outer_entries), false), true)
849            .with_metadata(parquet_field_id_metadata(Some(1)));
850        ArrowSchema::new(vec![top_field])
851    }
852
853    /// Flat schema: `[id: long, name: string]`
854    pub(crate) fn test_schema_flat() -> SchemaRef {
855        schema_ref! {
856            nullable "id": LONG,
857            nullable "name": STRING,
858        }
859    }
860
861    /// Flat schema with column mapping metadata.
862    pub(crate) fn test_schema_flat_with_column_mapping() -> SchemaRef {
863        schema_ref! {
864            (cm_field("id", 1, "phys_id", KernelDataType::LONG)),
865            (cm_field("name", 2, "phys_name", KernelDataType::STRING)),
866        }
867    }
868
869    /// Nested struct schema with array and map inside the struct
870    pub(crate) fn test_schema_nested() -> SchemaRef {
871        schema_ref! {
872            nullable "id": LONG,
873            nullable "info": {
874                nullable "name": STRING,
875                nullable "age": INTEGER,
876                nullable "tags": { STRING => nullable STRING },
877                nullable "scores": [ nullable INTEGER ],
878            },
879        }
880    }
881
882    /// Nested struct schema with column mapping metadata.
883    pub(crate) fn test_schema_nested_with_column_mapping() -> SchemaRef {
884        schema_ref! {
885            (cm_field("id", 1, "phys_id", KernelDataType::LONG)),
886            (cm_field("info", 2, "phys_info", schema! {
887                (cm_field("name", 3, "phys_name", KernelDataType::STRING)),
888                (cm_field("age", 4, "phys_age", KernelDataType::INTEGER)),
889                (cm_field("tags", 5, "phys_tags",
890                    MapType::new(KernelDataType::STRING, KernelDataType::STRING, true))),
891                (cm_field("scores", 6, "phys_scores",
892                    ArrayType::new(KernelDataType::INTEGER, true))),
893            })),
894        }
895    }
896
897    /// Schema with a map
898    pub(crate) fn test_schema_with_map() -> SchemaRef {
899        schema_ref! {
900            nullable "id": LONG,
901            nullable "entries": { STRING => nullable {
902                nullable "key": STRING,
903                nullable "value": INTEGER,
904            } },
905            nullable "name": STRING,
906        }
907    }
908
909    /// Schema with a map and column mapping metadata.
910    pub(crate) fn test_schema_with_map_and_column_mapping() -> SchemaRef {
911        let value_struct = schema! {
912            (cm_field("key", 4, "phys_key", KernelDataType::STRING)),
913            (cm_field("value", 5, "phys_value", KernelDataType::INTEGER)),
914        };
915        schema_ref! {
916            (cm_field("id", 1, "phys_id", KernelDataType::LONG)),
917            (cm_field("entries", 2, "phys_entries",
918                MapType::new(KernelDataType::STRING, value_struct, true))),
919            (cm_field("name", 3, "phys_name", KernelDataType::STRING)),
920        }
921    }
922
923    /// Schema with an array
924    pub(crate) fn test_schema_with_array() -> SchemaRef {
925        schema_ref! {
926            nullable "id": LONG,
927            nullable "items": [ nullable {
928                nullable "label": STRING,
929                nullable "count": INTEGER,
930            } ],
931            nullable "name": STRING,
932        }
933    }
934
935    /// Schema with an array and column mapping metadata.
936    pub(crate) fn test_schema_with_array_and_column_mapping() -> SchemaRef {
937        let item_struct = schema! {
938            (cm_field("label", 4, "phys_label", KernelDataType::STRING)),
939            (cm_field("count", 5, "phys_count", KernelDataType::INTEGER)),
940        };
941        schema_ref! {
942            (cm_field("id", 1, "phys_id", KernelDataType::LONG)),
943            (cm_field("items", 2, "phys_items", ArrayType::new(item_struct, true))),
944            (cm_field("name", 3, "phys_name", KernelDataType::STRING)),
945        }
946    }
947
948    /// Deeply nested schema: struct -> array -> struct -> map(value) -> struct.
949    ///
950    /// The leaf struct field is intentionally **not** annotated with column mapping metadata,
951    /// so this schema can be used to test error paths when column mapping is enabled.
952    pub(crate) fn test_deep_nested_schema_missing_leaf_cm() -> StructType {
953        let map_type = MapType::new(
954            KernelDataType::STRING,
955            schema! { not_null "leaf": INTEGER },
956            true,
957        );
958        let array_type = ArrayType::new(
959            schema! { (cm_field("mid_field", 2, "phys_mid_field", map_type)) },
960            true,
961        );
962        schema! {
963            (cm_field("top", 1, "phys_top", array_type)),
964        }
965    }
966
967    /// Build a create-table transaction with the given schema and column mapping mode.
968    /// Returns the engine and uncommitted transaction.
969    pub(crate) fn setup_column_mapping_txn(
970        schema: SchemaRef,
971        mode: ColumnMappingMode,
972    ) -> DeltaResult<(Arc<dyn Engine>, Transaction<CreateTable>)> {
973        let mode_str = match mode {
974            ColumnMappingMode::Name => "name",
975            ColumnMappingMode::Id => "id",
976            ColumnMappingMode::None => "none",
977        };
978        let store = Arc::new(InMemory::new());
979        let engine: Arc<dyn Engine> = Arc::new(SyncEngine::new_with_store(store));
980
981        let txn = create_table("memory:///test_table", schema, "DefaultEngine")
982            .with_table_properties([("delta.columnMapping.mode", mode_str)])
983            .build(engine.as_ref(), Box::new(FileSystemCommitter::new()))?;
984        Ok((engine, txn))
985    }
986
987    /// Validate that a physical schema matches the logical schema's column mapping metadata.
988    /// For Name/Id modes, checks physicalName, columnMapping.id, and parquet.field.id on
989    /// each field. For None mode, only checks field names match.
990    pub(crate) fn validate_physical_schema_column_mapping(
991        logical_schema: &StructType,
992        physical_schema: &StructType,
993        mode: ColumnMappingMode,
994    ) {
995        assert_eq!(
996            physical_schema.fields().count(),
997            logical_schema.fields().count()
998        );
999
1000        // Collect expected (physical_name, field_id) from logical schema
1001        let expected: Vec<_> = logical_schema
1002            .fields()
1003            .map(|f| {
1004                let physical_name =
1005                    match f.get_config_value(&ColumnMetadataKey::ColumnMappingPhysicalName) {
1006                        Some(MetadataValue::String(name)) => name.clone(),
1007                        _ if mode == ColumnMappingMode::None => f.name().to_string(),
1008                        _ => panic!("Logical field '{}' missing physicalName metadata", f.name()),
1009                    };
1010                let field_id = match f.get_config_value(&ColumnMetadataKey::ColumnMappingId) {
1011                    Some(MetadataValue::Number(id)) => *id,
1012                    _ if mode == ColumnMappingMode::None => -1,
1013                    _ => panic!(
1014                        "Logical field '{}' missing columnMapping.id metadata",
1015                        f.name()
1016                    ),
1017                };
1018                (physical_name, field_id)
1019            })
1020            .collect();
1021
1022        // Validate each physical field against expected values
1023        for (physical_field, (expected_name, expected_id)) in
1024            physical_schema.fields().zip(expected.iter())
1025        {
1026            assert_eq!(
1027                physical_field.name(),
1028                expected_name,
1029                "Physical field name mismatch"
1030            );
1031
1032            if mode == ColumnMappingMode::None {
1033                continue;
1034            }
1035
1036            assert_eq!(
1037                physical_field.get_config_value(&ColumnMetadataKey::ColumnMappingPhysicalName),
1038                Some(&MetadataValue::String(expected_name.clone())),
1039                "columnMapping.physicalName mismatch for '{}'",
1040                physical_field.name()
1041            );
1042
1043            assert_eq!(
1044                physical_field.get_config_value(&ColumnMetadataKey::ColumnMappingId),
1045                Some(&MetadataValue::Number(*expected_id)),
1046                "columnMapping.id mismatch for '{}'",
1047                physical_field.name()
1048            );
1049
1050            assert_eq!(
1051                physical_field.get_config_value(&ColumnMetadataKey::ParquetFieldId),
1052                Some(&MetadataValue::Number(*expected_id)),
1053                "parquet.field.id mismatch for '{}'",
1054                physical_field.name()
1055            );
1056        }
1057    }
1058
1059    /// Load a test table from tests/data directory.
1060    /// Tries compressed (tar.zst) first, falls back to extracted.
1061    /// Returns (engine, snapshot, optional tempdir). The TempDir must be kept alive
1062    /// for the duration of the test to prevent premature cleanup of extracted files.
1063    pub(crate) fn load_test_table(
1064        table_name: &str,
1065    ) -> DeltaResult<(Arc<dyn Engine>, SnapshotRef, Option<TempDir>)> {
1066        // Try loading compressed table first, fall back to extracted
1067        let (path, tempdir) = match load_test_data("tests/data", table_name) {
1068            Ok(test_dir) => {
1069                let test_path = test_dir.path().join(table_name);
1070                (test_path, Some(test_dir))
1071            }
1072            Err(_) => {
1073                // Fall back to already-extracted table
1074                let manifest_dir = env!("CARGO_MANIFEST_DIR");
1075                let mut path = PathBuf::from(manifest_dir);
1076                path.push("tests/data");
1077                path.push(table_name);
1078                let path = std::fs::canonicalize(path)
1079                    .map_err(|e| Error::Generic(format!("Failed to canonicalize path: {e}")))?;
1080                (path, None)
1081            }
1082        };
1083
1084        // Create engine and snapshot from the resolved path
1085        let url = Url::from_directory_path(&path)
1086            .map_err(|_| Error::Generic("Failed to create URL from path".to_string()))?;
1087
1088        let engine = Arc::new(SyncEngine::new());
1089        let snapshot = Snapshot::builder_for(url).build(engine.as_ref())?;
1090        Ok((engine, snapshot, tempdir))
1091    }
1092
1093    pub(crate) mod column_mapping_physical_name_dedup_fixtures {
1094        use crate::schema::{
1095            schema, ArrayType, ColumnMetadataKey, DataType, MapType, MetadataValue, StructField,
1096            StructType,
1097        };
1098
1099        /// Two fields with the same physical name at different physical paths should be accepted.
1100        pub(crate) fn same_phy_name_different_paths() -> StructType {
1101            let nested = StructType::new_unchecked([cm_field("id", 3, "id", DataType::INTEGER)]);
1102            StructType::new_unchecked([
1103                cm_field("id", 1, "id", DataType::INTEGER),
1104                cm_field("nested", 2, "nested", nested),
1105            ])
1106        }
1107
1108        /// Two nested fields with same physical path should be rejected.
1109        pub(crate) fn deeply_nested_repeat_physical_paths() -> StructType {
1110            let inner = StructType::new_unchecked([
1111                cm_field("a", 2, "x", DataType::INTEGER),
1112                cm_field("b", 3, "x", DataType::INTEGER),
1113            ]);
1114            let arr_of_struct = ArrayType::new(inner, true);
1115            let map_to_arr = MapType::new(DataType::STRING, arr_of_struct, true);
1116            StructType::new_unchecked([cm_field("outer", 1, "outer", map_to_arr)])
1117        }
1118
1119        /// Full logical paths of the two colliding fields in
1120        /// [`deeply_nested_repeat_physical_paths`], in the order the walker visits them.
1121        pub(crate) fn deeply_nested_collider_paths() -> (&'static str, &'static str) {
1122            (
1123                "outer.`<map value>`.`<array element>`.a",
1124                "outer.`<map value>`.`<array element>`.b",
1125            )
1126        }
1127
1128        /// Two collision sites in the same schema:
1129        /// - **shallower** (visited first by DFS): top-level siblings `a` and `b`, both have
1130        ///   physical name "p".
1131        /// - **deeper** (never reached): inside `nested` struct, siblings `x` and `y`, both have
1132        ///   physical name "q".
1133        ///
1134        /// Dedup must error at the shallower site and never report the deeper one.
1135        pub(crate) fn multiple_physical_name_collisions() -> StructType {
1136            schema! {
1137                (cm_field("a", 1, "p", schema! { (cm_field("aa", 6, "aa", DataType::INTEGER)) })),
1138                (cm_field("b", 2, "p", schema! { (cm_field("bb", 7, "bb", DataType::INTEGER)) })),
1139                (cm_field("nested", 3, "nested", schema! {
1140                    (cm_field("x", 4, "q", DataType::INTEGER)),
1141                    (cm_field("y", 5, "q", DataType::INTEGER)),
1142                })),
1143            }
1144        }
1145
1146        fn cm_field(name: &str, id: i64, phys: &str, ty: impl Into<DataType>) -> StructField {
1147            StructField::new(name, ty, true).with_metadata([
1148                (
1149                    ColumnMetadataKey::ColumnMappingId.as_ref(),
1150                    MetadataValue::Number(id),
1151                ),
1152                (
1153                    ColumnMetadataKey::ColumnMappingPhysicalName.as_ref(),
1154                    MetadataValue::String(phys.to_string()),
1155                ),
1156            ])
1157        }
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164
1165    #[test]
1166    fn test_path_parsing() {
1167        for x in [
1168            // windows parsing of file:/// is... odd
1169            #[cfg(not(windows))]
1170            "file:///foo/bar",
1171            #[cfg(not(windows))]
1172            "file:///foo/bar/",
1173            "/foo/bar",
1174            "/foo/bar/",
1175            "../foo/bar",
1176            "../foo/bar/",
1177            "c:/foo/bar",
1178            "c:/",
1179            "file:///C:/",
1180        ] {
1181            match resolve_uri_type(x) {
1182                Ok(UriType::LocalPath(_)) => {}
1183                x => panic!("Should have parsed as a local path {x:?}"),
1184            }
1185        }
1186
1187        for x in [
1188            "s3://foo/bar",
1189            "s3a://foo/bar",
1190            "memory://foo/bar",
1191            "gs://foo/bar",
1192            "https://foo/bar/",
1193            "unknown://foo/bar",
1194            "s2://foo/bar",
1195        ] {
1196            match resolve_uri_type(x) {
1197                Ok(UriType::Url(_)) => {}
1198                x => panic!("Should have parsed as a url {x:?}"),
1199            }
1200        }
1201
1202        #[cfg(not(windows))]
1203        resolve_uri_type("file://foo/bar").expect_err("file://foo/bar should not have parsed");
1204    }
1205
1206    #[test]
1207    fn try_from_uri_without_trailing_slash() {
1208        let location = "s3://foo/__unitystorage/catalogs/cid/tables/tid";
1209        let url = try_parse_uri(location).unwrap();
1210
1211        assert_eq!(
1212            url.to_string(),
1213            "s3://foo/__unitystorage/catalogs/cid/tables/tid/"
1214        );
1215    }
1216
1217    mod on_complete_tests {
1218        use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
1219        use std::sync::Arc;
1220
1221        use super::*;
1222
1223        #[test]
1224        fn test_calls_on_exhaustion() {
1225            let called = Arc::new(AtomicBool::new(false));
1226            let called_clone = called.clone();
1227            let mut iter = vec![1, 2].into_iter().on_complete(move || {
1228                called_clone.store(true, Ordering::SeqCst);
1229            });
1230            assert_eq!(iter.next(), Some(1));
1231            assert!(!called.load(Ordering::SeqCst));
1232            assert_eq!(iter.next(), Some(2));
1233            assert_eq!(iter.next(), None);
1234            assert!(called.load(Ordering::SeqCst));
1235        }
1236
1237        #[test]
1238        fn test_does_not_call_on_early_drop() {
1239            let called = Arc::new(AtomicBool::new(false));
1240            let called_clone = called.clone();
1241            {
1242                let mut iter = vec![1, 2].into_iter().on_complete(move || {
1243                    called_clone.store(true, Ordering::SeqCst);
1244                });
1245                assert_eq!(iter.next(), Some(1));
1246                // Drop without exhausting - callback should NOT be called
1247            }
1248            assert!(!called.load(Ordering::SeqCst));
1249        }
1250
1251        #[test]
1252        fn test_calls_only_once() {
1253            let count = Arc::new(AtomicU32::new(0));
1254            let count_clone = count.clone();
1255            {
1256                let mut iter = vec![1].into_iter().on_complete(move || {
1257                    count_clone.fetch_add(1, Ordering::SeqCst);
1258                });
1259                assert_eq!(iter.next(), Some(1));
1260                assert_eq!(iter.next(), None); // triggers callback
1261                assert_eq!(iter.next(), None); // should not trigger again
1262            } // drop should not trigger again
1263            assert_eq!(count.load(Ordering::SeqCst), 1);
1264        }
1265    }
1266}