Skip to main content

risingwave_connector/sink/iceberg/
create_table.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16use std::str::FromStr;
17use std::sync::LazyLock;
18
19use anyhow::{Context, anyhow};
20use iceberg::arrow::schema_to_arrow_schema;
21use iceberg::spec::{
22    FormatVersion, NullOrder, SortDirection, SortField, SortOrder, TableProperties, Transform,
23    UnboundPartitionField, UnboundPartitionSpec,
24};
25use iceberg::table::Table;
26use iceberg::{Catalog, NamespaceIdent, TableCreation};
27use itertools::Itertools;
28use regex::Regex;
29use risingwave_common::array::arrow::arrow_schema_iceberg::{
30    self, DataType as ArrowDataType, Field as ArrowField, Fields as ArrowFields,
31    Schema as ArrowSchema,
32};
33use risingwave_common::array::arrow::{IcebergArrowConvert, IcebergCreateTableArrowConvert};
34use risingwave_common::bail;
35use risingwave_common::catalog::Schema;
36use risingwave_common::util::iter_util::ZipEqFast;
37use url::Url;
38
39use super::{IcebergConfig, PARTITION_DATA_ID_START, SinkError};
40use crate::connector_common::{IcebergCatalogKind, IcebergCatalogRuntime};
41use crate::sink::{Result, SinkParam};
42
43static ORDER_KEY_COLUMN_RE: LazyLock<Regex> =
44    LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").expect("valid order key regex"));
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct IcebergOrderKeyField {
48    pub column: String,
49    pub direction: SortDirection,
50    pub null_order: NullOrder,
51}
52
53impl IcebergOrderKeyField {
54    fn default_null_order(direction: SortDirection) -> NullOrder {
55        match direction {
56            SortDirection::Ascending => NullOrder::First,
57            SortDirection::Descending => NullOrder::Last,
58        }
59    }
60}
61
62pub async fn create_and_validate_table_impl(
63    config: &IcebergConfig,
64    param: &SinkParam,
65) -> Result<Table> {
66    if config.create_table_if_not_exists {
67        create_table_if_not_exists_impl(config, param).await?;
68    }
69
70    let table = config
71        .load_table()
72        .await
73        .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
74
75    if config.enable_pk_index {
76        let table_format_version = table.metadata().format_version();
77        if table_format_version < FormatVersion::V2 {
78            return Err(SinkError::Config(anyhow!(
79                "`enable_pk_index` requires an Iceberg table with format version >= 2, \
80                 but the target table is format version {}",
81                table_format_version
82            )));
83        }
84    }
85
86    let sink_schema = param.schema();
87    let iceberg_arrow_schema = schema_to_arrow_schema(table.metadata().current_schema())
88        .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
89
90    try_matches_arrow_schema(&sink_schema, &iceberg_arrow_schema)
91        .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
92
93    Ok(table)
94}
95
96pub(super) async fn create_table_if_not_exists_impl(
97    config: &IcebergConfig,
98    param: &SinkParam,
99) -> Result<()> {
100    let catalog = config.create_catalog().await?;
101    let table_id = config
102        .full_table_name()
103        .context("Unable to parse table name")?;
104    let namespace = table_id.namespace().clone();
105    let table_name = table_id.name().to_owned();
106    create_namespace_if_not_exists(catalog.as_ref(), &namespace).await?;
107
108    if !catalog
109        .table_exists(&table_id)
110        .await
111        .map_err(|e| SinkError::Iceberg(anyhow!(e)))?
112    {
113        let iceberg_create_table_arrow_convert = IcebergCreateTableArrowConvert::default();
114        // convert risingwave schema -> arrow schema -> iceberg schema
115        let arrow_fields = param
116            .columns
117            .iter()
118            .map(|column| {
119                Ok(iceberg_create_table_arrow_convert
120                    .to_arrow_field(&column.name, &column.data_type)
121                    .map_err(|e| SinkError::Iceberg(anyhow!(e)))
122                    .context(format!(
123                        "failed to convert {}: {} to arrow type",
124                        column.name, column.data_type
125                    ))?)
126            })
127            .collect::<Result<Vec<ArrowField>>>()?;
128        let arrow_schema = arrow_schema_iceberg::Schema::new(arrow_fields);
129        let iceberg_schema = iceberg::arrow::arrow_schema_to_schema(&arrow_schema)
130            .map_err(|e| SinkError::Iceberg(anyhow!(e)))
131            .context("failed to convert arrow schema to iceberg schema")?;
132
133        let location = {
134            let mut names = namespace.clone().inner();
135            names.push(table_name.clone());
136            match &config.common.warehouse_path {
137                Some(warehouse_path) => {
138                    let is_s3_tables = warehouse_path.starts_with("arn:aws:s3tables");
139                    // Lakehouse Iceberg REST catalog federation uses bq:// prefix for BigQuery-managed Iceberg tables.
140                    let is_bq_catalog_federation = warehouse_path.starts_with("bq://");
141                    let url = Url::parse(warehouse_path);
142                    if url.is_err() || is_s3_tables || is_bq_catalog_federation {
143                        // For rest catalog, the warehouse_path could be a warehouse name.
144                        // In this case, we should specify the location when creating a table.
145                        if config
146                            .common
147                            .is_rest_catalog()
148                            .map_err(|err| SinkError::Config(anyhow!(err)))?
149                        {
150                            None
151                        } else {
152                            bail!(format!("Invalid warehouse path: {}", warehouse_path))
153                        }
154                    } else if warehouse_path.ends_with('/') {
155                        Some(format!("{}{}", warehouse_path, names.join("/")))
156                    } else {
157                        Some(format!("{}/{}", warehouse_path, names.join("/")))
158                    }
159                }
160                None => None,
161            }
162        };
163
164        let partition_spec = match &config.partition_by {
165            Some(partition_by) => {
166                let mut partition_fields = Vec::<UnboundPartitionField>::new();
167                for (i, (column, transform)) in parse_partition_by_exprs(partition_by.clone())?
168                    .into_iter()
169                    .enumerate()
170                {
171                    match iceberg_schema.field_id_by_name(&column) {
172                        Some(id) => partition_fields.push(
173                            UnboundPartitionField::builder()
174                                .source_id(id)
175                                .transform(transform)
176                                .name(format!("_p_{}", column))
177                                .field_id(PARTITION_DATA_ID_START + i as i32)
178                                .build(),
179                        ),
180                        None => bail!(format!(
181                            "Partition source column does not exist in schema: {}",
182                            column
183                        )),
184                    };
185                }
186                Some(
187                    UnboundPartitionSpec::builder()
188                        .with_spec_id(0)
189                        .add_partition_fields(partition_fields)
190                        .map_err(|e| SinkError::Iceberg(anyhow!(e)))
191                        .context("failed to add partition columns")?
192                        .build(),
193                )
194            }
195            None => None,
196        };
197
198        let sort_order = match &config.order_key {
199            Some(order_key) => Some(build_sort_order(order_key, &iceberg_schema)?),
200            None => None,
201        };
202
203        // Some JNI catalogs extract `format-version` from table properties, while
204        // native Rust Glue rejects reserved properties before creating metadata.
205        let properties = if matches!(
206            config.catalog_kind()?,
207            IcebergCatalogKind::Glue(IcebergCatalogRuntime::NativeRust)
208        ) {
209            HashMap::new()
210        } else {
211            HashMap::from([(
212                TableProperties::PROPERTY_FORMAT_VERSION.to_owned(),
213                (config.format_version as u8).to_string(),
214            )])
215        };
216
217        let table_creation_builder = TableCreation::builder()
218            .name(table_name)
219            .schema(iceberg_schema)
220            .format_version(config.table_format_version())
221            .properties(properties);
222
223        let table_creation = match (location, partition_spec, sort_order) {
224            (Some(location), Some(partition_spec), Some(sort_order)) => table_creation_builder
225                .location(location)
226                .partition_spec(partition_spec)
227                .sort_order(sort_order)
228                .build(),
229            (Some(location), Some(partition_spec), None) => table_creation_builder
230                .location(location)
231                .partition_spec(partition_spec)
232                .build(),
233            (Some(location), None, Some(sort_order)) => table_creation_builder
234                .location(location)
235                .sort_order(sort_order)
236                .build(),
237            (Some(location), None, None) => table_creation_builder.location(location).build(),
238            (None, Some(partition_spec), Some(sort_order)) => table_creation_builder
239                .partition_spec(partition_spec)
240                .sort_order(sort_order)
241                .build(),
242            (None, Some(partition_spec), None) => table_creation_builder
243                .partition_spec(partition_spec)
244                .build(),
245            (None, None, Some(sort_order)) => table_creation_builder.sort_order(sort_order).build(),
246            (None, None, None) => table_creation_builder.build(),
247        };
248
249        catalog
250            .create_table(&namespace, table_creation)
251            .await
252            .map_err(|e| SinkError::Iceberg(anyhow!(e)))
253            .context("failed to create iceberg table")?;
254    }
255    Ok(())
256}
257
258async fn create_namespace_if_not_exists(
259    catalog: &dyn Catalog,
260    namespace: &NamespaceIdent,
261) -> Result<()> {
262    let mut namespaces = vec![namespace.clone()];
263    let mut parent = namespace.parent();
264    while let Some(parent_namespace) = parent {
265        parent = parent_namespace.parent();
266        namespaces.push(parent_namespace);
267    }
268
269    for namespace in namespaces.into_iter().rev() {
270        if !catalog
271            .namespace_exists(&namespace)
272            .await
273            .map_err(|e| SinkError::Iceberg(anyhow!(e)))?
274        {
275            catalog
276                .create_namespace(&namespace, HashMap::default())
277                .await
278                .map_err(|e| SinkError::Iceberg(anyhow!(e)))
279                .with_context(|| format!("failed to create iceberg namespace: {namespace}"))?;
280        }
281    }
282
283    Ok(())
284}
285
286const MAP_KEY: &str = "key";
287const MAP_VALUE: &str = "value";
288
289fn get_fields<'a>(
290    our_field_type: &'a risingwave_common::types::DataType,
291    data_type: &ArrowDataType,
292    schema_fields: &mut HashMap<&'a str, &'a risingwave_common::types::DataType>,
293) -> Option<ArrowFields> {
294    match data_type {
295        ArrowDataType::Struct(fields) => {
296            match our_field_type {
297                risingwave_common::types::DataType::Struct(struct_fields) => {
298                    struct_fields.iter().for_each(|(name, data_type)| {
299                        let res = schema_fields.insert(name, data_type);
300                        // This assert is to make sure there is no duplicate field name in the schema.
301                        assert!(res.is_none())
302                    });
303                }
304                risingwave_common::types::DataType::Map(map_fields) => {
305                    schema_fields.insert(MAP_KEY, map_fields.key());
306                    schema_fields.insert(MAP_VALUE, map_fields.value());
307                }
308                risingwave_common::types::DataType::List(list) => {
309                    list.elem()
310                        .as_struct()
311                        .iter()
312                        .for_each(|(name, data_type)| {
313                            let res = schema_fields.insert(name, data_type);
314                            // This assert is to make sure there is no duplicate field name in the schema.
315                            assert!(res.is_none())
316                        });
317                }
318                _ => {}
319            };
320            Some(fields.clone())
321        }
322        ArrowDataType::List(field) | ArrowDataType::Map(field, _) => {
323            get_fields(our_field_type, field.data_type(), schema_fields)
324        }
325        _ => None, // not a supported complex type and unlikely to show up
326    }
327}
328
329fn check_compatibility(
330    schema_fields: HashMap<&str, &risingwave_common::types::DataType>,
331    fields: &ArrowFields,
332) -> anyhow::Result<bool> {
333    for arrow_field in fields {
334        let our_field_type = schema_fields
335            .get(arrow_field.name().as_str())
336            .ok_or_else(|| anyhow!("Field {} not found in our schema", arrow_field.name()))?;
337
338        // Iceberg source should be able to read iceberg decimal type.
339        let converted_arrow_data_type = IcebergArrowConvert
340            .to_arrow_field("", our_field_type)
341            .map_err(|e| anyhow!(e))?
342            .data_type()
343            .clone();
344
345        let compatible = match (&converted_arrow_data_type, arrow_field.data_type()) {
346            (ArrowDataType::Decimal128(_, _), ArrowDataType::Decimal128(_, _)) => true,
347            (ArrowDataType::Binary, ArrowDataType::LargeBinary) => true,
348            (ArrowDataType::LargeBinary, ArrowDataType::Binary) => true,
349            (ArrowDataType::List(_), ArrowDataType::List(field))
350            | (ArrowDataType::Map(_, _), ArrowDataType::Map(field, _)) => {
351                let mut schema_fields = HashMap::new();
352                get_fields(our_field_type, field.data_type(), &mut schema_fields)
353                    .is_none_or(|fields| check_compatibility(schema_fields, &fields).unwrap())
354            }
355            // validate nested structs
356            (ArrowDataType::Struct(_), ArrowDataType::Struct(fields)) => {
357                let mut schema_fields = HashMap::new();
358                our_field_type
359                    .as_struct()
360                    .iter()
361                    .for_each(|(name, data_type)| {
362                        let res = schema_fields.insert(name, data_type);
363                        // This assert is to make sure there is no duplicate field name in the schema.
364                        assert!(res.is_none())
365                    });
366                check_compatibility(schema_fields, fields)?
367            }
368            // cases where left != right (metadata, field name mismatch)
369            //
370            // all nested types: in iceberg `field_id` will always be present, but RW doesn't have it:
371            // {"PARQUET:field_id": ".."}
372            //
373            // map: The standard name in arrow is "entries", "key", "value".
374            // in iceberg-rs, it's called "key_value"
375            (left, right) => left.equals_datatype(right),
376        };
377        if !compatible {
378            bail!(
379                "field {}'s type is incompatible\nRisingWave converted data type: {}\niceberg's data type: {}",
380                arrow_field.name(),
381                converted_arrow_data_type,
382                arrow_field.data_type()
383            );
384        }
385    }
386    Ok(true)
387}
388
389/// Try to match our schema with iceberg schema.
390pub fn try_matches_arrow_schema(rw_schema: &Schema, arrow_schema: &ArrowSchema) -> Result<()> {
391    if rw_schema.fields.len() != arrow_schema.fields().len() {
392        bail!(
393            "Schema length mismatch, risingwave is {}, and iceberg is {}",
394            rw_schema.fields.len(),
395            arrow_schema.fields.len()
396        );
397    }
398
399    let mut schema_fields = HashMap::new();
400    rw_schema.fields.iter().for_each(|field| {
401        let res = schema_fields.insert(field.name.as_str(), &field.data_type);
402        // This assert is to make sure there is no duplicate field name in the schema.
403        assert!(res.is_none())
404    });
405
406    check_compatibility(schema_fields, &arrow_schema.fields)?;
407
408    // The sink writes columns to the Iceberg table by position, so the column order
409    // must match. The check above only validates the name set and types.
410    for (idx, (rw_field, arrow_field)) in rw_schema
411        .fields
412        .iter()
413        .zip_eq_fast(arrow_schema.fields().iter())
414        .enumerate()
415    {
416        if rw_field.name.as_str() != arrow_field.name().as_str() {
417            bail!(
418                "Column order mismatch at position {}: the sink has column `{}` but the \
419                 Iceberg table has column `{}`. The Iceberg sink maps columns to the table \
420                 by position, so the sink's column order must match the Iceberg table \
421                 columns [{}].",
422                idx,
423                rw_field.name,
424                arrow_field.name(),
425                arrow_schema.fields().iter().map(|f| f.name()).join(", "),
426            );
427        }
428    }
429
430    Ok(())
431}
432
433pub fn parse_partition_by_exprs(
434    expr: String,
435) -> std::result::Result<Vec<(String, Transform)>, anyhow::Error> {
436    // captures column, transform(column), transform(n,column), transform(n, column)
437    let re = Regex::new(r"(?<transform>\w+)(\(((?<n>\d+)?(?:,|(,\s)))?(?<field>\w+)\))?").unwrap();
438    if !re.is_match(&expr) {
439        bail!(format!(
440            "Invalid partition fields: {}\nHINT: Supported formats are column, transform(column), transform(n,column), transform(n, column)",
441            expr
442        ))
443    }
444    let caps = re.captures_iter(&expr);
445
446    let mut partition_columns = vec![];
447
448    for mat in caps {
449        let (column, transform) = if mat.name("n").is_none() && mat.name("field").is_none() {
450            (&mat["transform"], Transform::Identity)
451        } else {
452            let mut func = mat["transform"].to_owned();
453            if func == "bucket" || func == "truncate" {
454                let n = &mat
455                    .name("n")
456                    .ok_or_else(|| anyhow!("The `n` must be set with `bucket` and `truncate`"))?
457                    .as_str();
458                func = format!("{func}[{n}]");
459            }
460            (
461                &mat["field"],
462                Transform::from_str(&func)
463                    .with_context(|| format!("invalid transform function {}", func))?,
464            )
465        };
466        partition_columns.push((column.to_owned(), transform));
467    }
468    Ok(partition_columns)
469}
470
471pub fn parse_order_key_exprs(
472    expr: String,
473) -> std::result::Result<Vec<IcebergOrderKeyField>, anyhow::Error> {
474    let mut order_keys = Vec::new();
475    let mut seen_columns = std::collections::HashSet::new();
476
477    for raw_item in expr.split(',') {
478        let item = raw_item.trim();
479        if item.is_empty() {
480            bail!("Invalid order key: empty item in `{expr}`");
481        }
482
483        let tokens = item.split_whitespace().collect_vec();
484        if tokens.is_empty() {
485            bail!("Invalid order key item `{item}`");
486        }
487        if tokens.len() > 4 {
488            bail!(
489                "Invalid order key item `{item}`\nHINT: Supported format is `column [asc|desc] [nulls first|last]`"
490            );
491        }
492
493        let column = tokens[0];
494        if !ORDER_KEY_COLUMN_RE.is_match(column) {
495            bail!(
496                "Invalid order key column `{column}`\nHINT: Only plain column names are supported in order_key"
497            );
498        }
499        if !seen_columns.insert(column.to_ascii_lowercase()) {
500            bail!("Duplicate column `{column}` in order_key");
501        }
502
503        let mut direction = SortDirection::Ascending;
504        let mut null_order = None;
505        let mut idx = 1;
506        while idx < tokens.len() {
507            match tokens[idx].to_ascii_lowercase().as_str() {
508                "asc" => {
509                    direction = SortDirection::Ascending;
510                    idx += 1;
511                }
512                "desc" => {
513                    direction = SortDirection::Descending;
514                    idx += 1;
515                }
516                "nulls" => {
517                    let order = tokens.get(idx + 1).ok_or_else(|| {
518                        anyhow!(
519                            "Invalid order key item `{item}`: `NULLS` must be followed by `FIRST` or `LAST`"
520                        )
521                    })?;
522                    null_order = Some(match order.to_ascii_lowercase().as_str() {
523                        "first" => NullOrder::First,
524                        "last" => NullOrder::Last,
525                        _ => bail!(
526                            "Invalid order key item `{item}`\nHINT: `NULLS` must be followed by `FIRST` or `LAST`"
527                        ),
528                    });
529                    idx += 2;
530                }
531                token => {
532                    bail!(
533                        "Invalid order key token `{token}` in `{item}`\nHINT: Supported format is `column [asc|desc] [nulls first|last]`"
534                    );
535                }
536            }
537        }
538
539        order_keys.push(IcebergOrderKeyField {
540            column: column.to_owned(),
541            direction,
542            null_order: null_order
543                .unwrap_or_else(|| IcebergOrderKeyField::default_null_order(direction)),
544        });
545    }
546
547    if order_keys.is_empty() {
548        bail!("order_key must not be empty");
549    }
550
551    Ok(order_keys)
552}
553
554pub fn validate_order_key_columns<'a>(
555    order_key: &str,
556    columns: impl IntoIterator<Item = &'a str>,
557) -> std::result::Result<Vec<IcebergOrderKeyField>, anyhow::Error> {
558    let parsed = parse_order_key_exprs(order_key.to_owned())?;
559    let columns = columns
560        .into_iter()
561        .map(|column| column.to_ascii_lowercase())
562        .collect::<std::collections::HashSet<_>>();
563    for item in &parsed {
564        if item.column.starts_with('_') {
565            bail!(
566                "System column `{}` is not allowed in order_key",
567                item.column
568            );
569        }
570        if !columns.contains(&item.column.to_ascii_lowercase()) {
571            bail!("Order key column does not exist in schema: {}", item.column);
572        }
573    }
574    Ok(parsed)
575}
576
577fn build_sort_order(order_key: &str, schema: &iceberg::spec::Schema) -> Result<SortOrder> {
578    let order_fields = validate_order_key_columns(
579        order_key,
580        schema
581            .as_struct()
582            .fields()
583            .iter()
584            .map(|field| field.name.as_str()),
585    )?;
586    let mut builder = SortOrder::builder();
587    for field in order_fields {
588        let source_id = schema.field_id_by_name(&field.column).ok_or_else(|| {
589            anyhow!(
590                "Order key column does not exist in schema: {}",
591                field.column
592            )
593        })?;
594        builder.with_sort_field(
595            SortField::builder()
596                .source_id(source_id)
597                .transform(Transform::Identity)
598                .direction(field.direction)
599                .null_order(field.null_order)
600                .build(),
601        );
602    }
603    builder
604        .build(schema)
605        .map_err(|e| SinkError::Iceberg(anyhow!(e)))
606}