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