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