Skip to main content

polars_core/schema/
iceberg.rs

1//! TODO
2//!
3//! This should ideally be moved to `polars-schema`, currently it cannot due to dependency on
4//! `polars_core::DataType`.
5use std::borrow::Cow;
6use std::sync::Arc;
7
8use polars_arrow::datatypes::{ArrowDataType, ArrowSchema, Field as ArrowField};
9use polars_error::{PolarsResult, feature_gated, polars_bail, polars_err};
10use polars_utils::aliases::{InitHashMaps, PlHashMap};
11use polars_utils::pl_str::PlSmallStr;
12
13use crate::prelude::{DataType, Field, PlIndexMap};
14
15pub const LIST_ELEMENT_DEFAULT_ID: u32 = u32::MAX;
16
17/// Maps Iceberg physical IDs to columns.
18///
19/// Note: This doesn't use `Schema<F>` as the keys are u32's.
20#[derive(Debug, Clone, Eq, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[cfg_attr(feature = "dsl-schema", derive(schemars::JsonSchema))]
23pub struct IcebergSchema(PlIndexMap<u32, IcebergColumn>);
24pub type IcebergSchemaRef = Arc<IcebergSchema>;
25
26impl IcebergSchema {
27    /// Constructs a schema keyed by the physical ID stored in the arrow field metadata.
28    pub fn from_arrow_schema(schema: &ArrowSchema) -> PolarsResult<Self> {
29        Self::try_from_arrow_fields_iter(schema.iter_values())
30    }
31
32    pub fn try_from_arrow_fields_iter<'a, I>(iter: I) -> PolarsResult<Self>
33    where
34        I: IntoIterator<Item = &'a ArrowField>,
35    {
36        let iter = iter.into_iter();
37
38        let mut out = PlIndexMap::with_capacity(iter.size_hint().0);
39
40        for arrow_field in iter {
41            let col: IcebergColumn = arrow_field_to_iceberg_column_rec(arrow_field, None)?;
42            let existing = out.insert(col.physical_id, col);
43
44            if let Some(existing) = existing {
45                polars_bail!(
46                    Duplicate:
47                    "IcebergSchema: duplicate physical ID {:?}",
48                    existing,
49                )
50            }
51        }
52
53        Ok(Self(out))
54    }
55}
56
57#[derive(Debug, Clone, Eq, Hash, PartialEq)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59#[cfg_attr(feature = "dsl-schema", derive(schemars::JsonSchema))]
60pub struct IcebergColumn {
61    /// Output name
62    pub name: PlSmallStr,
63    /// This is expected to map from 'PARQUET:field_id'
64    pub physical_id: u32,
65    pub type_: IcebergColumnType,
66    /// Names of the field in data files without field IDs, from the table's name mapping
67    /// (`schema.name-mapping.default`).
68    pub mapped_names: Vec<PlSmallStr>,
69}
70
71#[derive(Debug, Clone, Eq, Hash, PartialEq)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73#[cfg_attr(feature = "dsl-schema", derive(schemars::JsonSchema))]
74pub enum IcebergColumnType {
75    Primitive {
76        /// This must not be a nested data type.
77        dtype: DataType,
78    },
79    List(Box<IcebergColumn>),
80    /// (values, width)
81    FixedSizeList(Box<IcebergColumn>, usize),
82    /// (keys, values)
83    Map(Box<IcebergColumn>, Box<IcebergColumn>),
84    Struct(IcebergSchema),
85}
86
87impl IcebergColumnType {
88    pub fn to_polars_dtype(&self) -> DataType {
89        use IcebergColumnType::*;
90
91        match self {
92            Primitive { dtype } => dtype.clone(),
93            List(inner) => DataType::List(Box::new(inner.type_.to_polars_dtype())),
94            FixedSizeList(inner, width) => {
95                feature_gated!("dtype-array", {
96                    DataType::Array(Box::new(inner.type_.to_polars_dtype()), *width)
97                })
98            },
99            Map(key, value) => feature_gated!("dtype-map", {
100                DataType::Map(
101                    Box::new(key.type_.to_polars_dtype()),
102                    Box::new(value.type_.to_polars_dtype()),
103                )
104            }),
105            Struct(fields) => feature_gated!("dtype-struct", {
106                DataType::Struct(
107                    fields
108                        .values()
109                        .map(|col| Field::new(col.name.clone(), col.type_.to_polars_dtype()))
110                        .collect(),
111                )
112            }),
113        }
114    }
115
116    pub fn is_nested(&self) -> bool {
117        use IcebergColumnType::*;
118
119        match self {
120            List(_) | FixedSizeList(..) | Map(..) | Struct(_) => true,
121            Primitive { .. } => false,
122        }
123    }
124}
125
126fn arrow_field_to_iceberg_column_rec(
127    field: &ArrowField,
128    field_id_override: Option<u32>,
129) -> PolarsResult<IcebergColumn> {
130    let physical_id: u32 = field_id_override.ok_or(Cow::Borrowed("")).or_else(|_| {
131        field
132            .metadata
133            .as_deref()
134            .ok_or(Cow::Borrowed("metadata was None"))
135            .and_then(|md| {
136                md.get(PARQUET_FIELD_ID_KEY)
137                    .ok_or(Cow::Borrowed("key not found in metadata"))
138            })
139            .and_then(|x| {
140                x.parse()
141                    .map_err(|_| Cow::Owned(format!("could not parse value as u32: '{x}'")))
142            })
143            .map_err(|failed_reason: Cow<'_, str>| {
144                polars_err!(
145                    SchemaFieldNotFound:
146                    "IcebergSchema: failed to load '{PARQUET_FIELD_ID_KEY}' for field {}: {}",
147                    &field.name,
148                    failed_reason,
149                )
150            })
151    })?;
152
153    // Prevent accidental re-use.
154    #[expect(unused)]
155    let field_id_override: ();
156
157    use ArrowDataType as ADT;
158
159    let name = field.name.clone();
160
161    let list_column_type = |field: &ArrowField| -> PolarsResult<IcebergColumnType> {
162        let field_id_override = field
163            .metadata
164            .as_ref()
165            .is_none_or(|x| !x.contains_key(PARQUET_FIELD_ID_KEY))
166            .then_some(LIST_ELEMENT_DEFAULT_ID);
167
168        Ok(IcebergColumnType::List(Box::new(
169            arrow_field_to_iceberg_column_rec(field, field_id_override)?,
170        )))
171    };
172
173    let type_ = match &field.dtype {
174        ADT::List(field) | ADT::LargeList(field) => list_column_type(field)?,
175
176        ADT::Map(entries, _) => {
177            #[cfg(feature = "dtype-map")]
178            {
179                // The `entries` field itself carries no physical ID - the IDs sit on the `key` and
180                // `value` fields underneath it.
181                let ADT::Struct(entry_fields) = &entries.dtype else {
182                    polars_bail!(
183                        ComputeError:
184                        "IcebergSchema: expected struct under arrow map type, got: {:?}",
185                        &entries.dtype,
186                    )
187                };
188
189                let [key, value] = entry_fields.as_slice() else {
190                    polars_bail!(
191                        ComputeError:
192                        "IcebergSchema: expected 2 fields under arrow map type, got: {}",
193                        entry_fields.len(),
194                    )
195                };
196
197                IcebergColumnType::Map(
198                    Box::new(arrow_field_to_iceberg_column_rec(key, None)?),
199                    Box::new(arrow_field_to_iceberg_column_rec(value, None)?),
200                )
201            }
202            #[cfg(not(feature = "dtype-map"))]
203            {
204                // Without the `Map` dtype these columns load as `List(Struct { key, value })`.
205                list_column_type(entries)?
206            }
207        },
208
209        #[cfg(feature = "dtype-array")]
210        ADT::FixedSizeList(field, width) => IcebergColumnType::FixedSizeList(
211            Box::new(arrow_field_to_iceberg_column_rec(field, None)?),
212            *width,
213        ),
214
215        #[cfg(feature = "dtype-struct")]
216        ADT::Struct(fields) => {
217            IcebergColumnType::Struct(IcebergSchema::try_from_arrow_fields_iter(fields)?)
218        },
219
220        dtype => {
221            if let ADT::Dictionary(_key_type, value_type, _is_ordered) = dtype
222                && !value_type.is_nested()
223            {
224                let mut new_field = ArrowField::new(name.clone(), dtype.clone(), true);
225                if let Some(metadata) = field.metadata.as_ref() {
226                    new_field = new_field.with_metadata((**metadata).clone());
227                }
228                let dtype = DataType::from_arrow_field(&new_field);
229
230                IcebergColumnType::Primitive { dtype }
231            } else if let ADT::Extension(ext_type) = dtype
232                && let DataType::Binary = DataType::from_arrow_dtype(&ext_type.inner)
233            {
234                // Iceberg UUID type will hit this branch.
235                IcebergColumnType::Primitive {
236                    dtype: DataType::Binary,
237                }
238            } else if dtype.is_nested() {
239                polars_bail!(
240                    ComputeError:
241                    "IcebergSchema: unsupported arrow type: {:?}",
242                    dtype,
243                )
244            } else {
245                let dtype =
246                    DataType::from_arrow_field(&ArrowField::new(name.clone(), dtype.clone(), true));
247
248                IcebergColumnType::Primitive { dtype }
249            }
250        },
251    };
252
253    let out = IcebergColumn {
254        name,
255        physical_id,
256        type_,
257        mapped_names: vec![],
258    };
259
260    Ok(out)
261}
262
263const PARQUET_FIELD_ID_KEY: &str = "PARQUET:field_id";
264
265impl IcebergSchema {
266    /// Sets the mapped names of the columns (and their nested fields) from the table's name
267    /// mapping, keyed by field ID.
268    pub fn apply_name_mapping(&mut self, names: &PlHashMap<u32, Vec<PlSmallStr>>) {
269        fn apply_column(col: &mut IcebergColumn, names: &PlHashMap<u32, Vec<PlSmallStr>>) {
270            if let Some(n) = names.get(&col.physical_id) {
271                col.mapped_names = n.clone();
272            }
273            match &mut col.type_ {
274                IcebergColumnType::Primitive { .. } => {},
275                IcebergColumnType::List(inner) | IcebergColumnType::FixedSizeList(inner, _) => {
276                    apply_column(inner, names)
277                },
278                IcebergColumnType::Map(key, value) => {
279                    apply_column(key, names);
280                    apply_column(value, names);
281                },
282                IcebergColumnType::Struct(fields) => fields.apply_name_mapping(names),
283            }
284        }
285
286        for col in self.values_mut() {
287            apply_column(col, names);
288        }
289    }
290
291    /// The schema of a data file, as [`Self::from_arrow_schema`]. A file written without field
292    /// IDs (e.g. imported with `add_files`) is read with the table's name mapping, which
293    /// `table` (the projected table columns) carries if `table_has_name_mapping`: its fields
294    /// take the ID of the table field whose mapped names hold their name, as in Iceberg Java.
295    ///
296    /// Fields without an ID (unmapped fields, or fields of a file that has IDs on only some
297    /// fields) get IDs that match no table field, so they read as missing, as in Iceberg Java.
298    pub fn from_arrow_schema_with_name_mapping(
299        schema: &ArrowSchema,
300        table: &IcebergSchema,
301        table_has_name_mapping: bool,
302    ) -> PolarsResult<Self> {
303        let has_field_ids = schema.iter_values().any(has_field_id);
304
305        if has_field_ids {
306            let fields = with_placeholder_ids_of_level(schema.iter_values());
307            return Self::try_from_arrow_fields_iter(fields.iter());
308        }
309
310        if !table_has_name_mapping {
311            return Self::from_arrow_schema(schema);
312        }
313
314        let fields = with_mapped_ids_of_level(schema.iter_values(), table);
315        Self::try_from_arrow_fields_iter(fields.iter())
316    }
317}
318
319fn has_field_id(field: &ArrowField) -> bool {
320    field
321        .metadata
322        .as_ref()
323        .is_some_and(|md| md.contains_key(PARQUET_FIELD_ID_KEY))
324}
325
326/// The fields of one struct level with the field IDs of the `table` columns they are mapped to.
327fn with_mapped_ids_of_level<'a>(
328    fields: impl IntoIterator<Item = &'a ArrowField>,
329    table: &IcebergSchema,
330) -> Vec<ArrowField> {
331    // IDs of unmapped fields, unique within the level; they keep their position, which struct
332    // field selection relies on.
333    let mut unmapped_id = LIST_ELEMENT_DEFAULT_ID;
334
335    fields
336        .into_iter()
337        .map(|field| {
338            match table
339                .values()
340                .find(|c| c.mapped_names.iter().any(|n| n == &field.name))
341            {
342                Some(column) => with_mapped_ids(field, column),
343                None => {
344                    unmapped_id -= 1;
345                    with_placeholder_ids(field, unmapped_id)
346                },
347            }
348        })
349        .collect()
350}
351
352/// The fields of one struct level, where fields without a field ID get IDs that are unique
353/// within the level and match no table field.
354fn with_placeholder_ids_of_level<'a>(
355    fields: impl IntoIterator<Item = &'a ArrowField>,
356) -> Vec<ArrowField> {
357    let mut placeholder_id = LIST_ELEMENT_DEFAULT_ID;
358
359    fields
360        .into_iter()
361        .map(|field| {
362            placeholder_id -= 1;
363            with_placeholder_ids(field, placeholder_id)
364        })
365        .collect()
366}
367
368/// `field` with `id` if it has no field ID, and placeholder IDs for its nested fields without
369/// one. List elements without an ID are left to [`LIST_ELEMENT_DEFAULT_ID`].
370fn with_placeholder_ids(field: &ArrowField, id: u32) -> ArrowField {
371    let field = ArrowField {
372        dtype: with_placeholder_ids_dtype(&field.dtype),
373        ..field.clone()
374    };
375
376    if has_field_id(&field) {
377        field
378    } else {
379        with_field_id(field, id)
380    }
381}
382
383fn with_placeholder_ids_dtype(dtype: &ArrowDataType) -> ArrowDataType {
384    use ArrowDataType as ADT;
385
386    let list_element = |inner: &ArrowField| ArrowField {
387        dtype: with_placeholder_ids_dtype(&inner.dtype),
388        ..inner.clone()
389    };
390
391    match dtype {
392        ADT::Struct(fields) => ADT::Struct(with_placeholder_ids_of_level(fields)),
393        ADT::List(inner) => ADT::List(Box::new(list_element(inner))),
394        ADT::LargeList(inner) => ADT::LargeList(Box::new(list_element(inner))),
395        ADT::FixedSizeList(inner, width) => ADT::FixedSizeList(
396            Box::new(with_placeholder_ids(inner, LIST_ELEMENT_DEFAULT_ID - 1)),
397            *width,
398        ),
399        ADT::Map(entries, sorted) => match &entries.dtype {
400            ADT::Struct(kv) if kv.len() == 2 => {
401                let entries = ArrowField {
402                    dtype: ADT::Struct(with_placeholder_ids_of_level(kv)),
403                    ..(**entries).clone()
404                };
405                ADT::Map(Box::new(entries), *sorted)
406            },
407            dtype => dtype.clone(),
408        },
409        dtype => dtype.clone(),
410    }
411}
412
413/// `field` with the field IDs of `column` and its nested fields. List elements and map keys and
414/// values are matched by position.
415fn with_mapped_ids(field: &ArrowField, column: &IcebergColumn) -> ArrowField {
416    use ArrowDataType as ADT;
417    use IcebergColumnType as ICT;
418
419    let dtype = match (&field.dtype, &column.type_) {
420        (ADT::Struct(fields), ICT::Struct(children)) => {
421            ADT::Struct(with_mapped_ids_of_level(fields, children))
422        },
423        (ADT::List(inner), ICT::List(c)) => ADT::List(Box::new(with_mapped_ids(inner, c))),
424        (ADT::LargeList(inner), ICT::List(c)) => {
425            ADT::LargeList(Box::new(with_mapped_ids(inner, c)))
426        },
427        (ADT::FixedSizeList(inner, width), ICT::FixedSizeList(c, _)) => {
428            ADT::FixedSizeList(Box::new(with_mapped_ids(inner, c)), *width)
429        },
430        (ADT::Map(entries, sorted), ICT::Map(key, value)) => match &entries.dtype {
431            ADT::Struct(kv) if kv.len() == 2 => {
432                let entries = ArrowField {
433                    dtype: ADT::Struct(vec![
434                        with_mapped_ids(&kv[0], key),
435                        with_mapped_ids(&kv[1], value),
436                    ]),
437                    ..(**entries).clone()
438                };
439                ADT::Map(Box::new(entries), *sorted)
440            },
441            dtype => dtype.clone(),
442        },
443        // Other (e.g. mismatched) types are left for the schema checks.
444        (dtype, _) => with_placeholder_ids_dtype(dtype),
445    };
446
447    with_field_id(
448        ArrowField {
449            dtype,
450            ..field.clone()
451        },
452        column.physical_id,
453    )
454}
455
456fn with_field_id(mut field: ArrowField, id: u32) -> ArrowField {
457    let mut metadata = field.metadata.as_deref().cloned().unwrap_or_default();
458    metadata.insert(
459        PlSmallStr::from_static(PARQUET_FIELD_ID_KEY),
460        id.to_string().into(),
461    );
462    field.metadata = Some(Arc::new(metadata));
463    field
464}
465
466impl<T> FromIterator<T> for IcebergSchema
467where
468    PlIndexMap<u32, IcebergColumn>: FromIterator<T>,
469{
470    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
471        Self(PlIndexMap::<u32, IcebergColumn>::from_iter(iter))
472    }
473}
474
475impl std::hash::Hash for IcebergSchema {
476    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
477        for col in self.values() {
478            col.hash(state);
479        }
480    }
481}
482
483impl std::ops::Deref for IcebergSchema {
484    type Target = PlIndexMap<u32, IcebergColumn>;
485
486    fn deref(&self) -> &Self::Target {
487        &self.0
488    }
489}
490
491impl std::ops::DerefMut for IcebergSchema {
492    fn deref_mut(&mut self) -> &mut Self::Target {
493        &mut self.0
494    }
495}