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