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::spec::{
22 FormatVersion, NullOrder, SortDirection, SortField, SortOrder, TableProperties, Transform,
23 UnboundPartitionField, UnboundPartitionSpec,
24};
25use iceberg::table::Table;
26use iceberg::{Catalog, NamespaceIdent, TableCreation};
27use itertools::Itertools;
28use regex::Regex;
29use risingwave_common::array::arrow::arrow_schema_iceberg::{
30 self, DataType as ArrowDataType, Field as ArrowField, Fields as ArrowFields,
31 Schema as ArrowSchema,
32};
33use risingwave_common::array::arrow::{IcebergArrowConvert, IcebergCreateTableArrowConvert};
34use risingwave_common::bail;
35use risingwave_common::catalog::Schema;
36use risingwave_common::util::iter_util::ZipEqFast;
37use url::Url;
38
39use super::{IcebergConfig, PARTITION_DATA_ID_START, SinkError};
40use crate::connector_common::{IcebergCatalogKind, IcebergCatalogRuntime};
41use crate::sink::{Result, SinkParam};
42
43static ORDER_KEY_COLUMN_RE: LazyLock<Regex> =
44 LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").expect("valid order key regex"));
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct IcebergOrderKeyField {
48 pub column: String,
49 pub direction: SortDirection,
50 pub null_order: NullOrder,
51}
52
53impl IcebergOrderKeyField {
54 fn default_null_order(direction: SortDirection) -> NullOrder {
55 match direction {
56 SortDirection::Ascending => NullOrder::First,
57 SortDirection::Descending => NullOrder::Last,
58 }
59 }
60}
61
62pub async fn create_and_validate_table_impl(
63 config: &IcebergConfig,
64 param: &SinkParam,
65) -> Result<Table> {
66 if config.create_table_if_not_exists {
67 create_table_if_not_exists_impl(config, param).await?;
68 }
69
70 let table = config
71 .load_table()
72 .await
73 .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
74
75 if config.enable_pk_index {
76 let table_format_version = table.metadata().format_version();
77 if table_format_version < FormatVersion::V2 {
78 return Err(SinkError::Config(anyhow!(
79 "`enable_pk_index` requires an Iceberg table with format version >= 2, \
80 but the target table is format version {}",
81 table_format_version
82 )));
83 }
84 }
85
86 let sink_schema = param.schema();
87 let iceberg_arrow_schema = schema_to_arrow_schema(table.metadata().current_schema())
88 .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
89
90 try_matches_arrow_schema(&sink_schema, &iceberg_arrow_schema)
91 .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
92
93 Ok(table)
94}
95
96pub(super) async fn create_table_if_not_exists_impl(
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 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 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 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 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 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 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, }
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 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 (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 assert!(res.is_none())
368 });
369 check_compatibility(schema_fields, fields)?
370 }
371 (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
392pub 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 assert!(res.is_none())
407 });
408
409 check_compatibility(schema_fields, &arrow_schema.fields)?;
410
411 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 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}