1use 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
99pub 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
125pub(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(), ¶m.columns)?;
158
159 let iceberg_create_table_arrow_convert = IcebergCreateTableArrowConvert::default();
160 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 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 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 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 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 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
458pub 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 assert!(res.is_none())
473 });
474
475 check_compatibility(schema_fields, &arrow_schema.fields)?;
476
477 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 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}