1use std::collections::{BTreeMap, HashMap, HashSet};
16use std::rc::Rc;
17use std::sync::Arc;
18
19use anyhow::Context;
20use clap::ValueEnum;
21use either::Either;
22use fixedbitset::FixedBitSet;
23use iceberg::spec::FormatVersion;
24use itertools::Itertools;
25use percent_encoding::percent_decode_str;
26use pgwire::pg_response::{PgResponse, StatementType};
27use prost::Message as _;
28use risingwave_common::acl::AclMode;
29use risingwave_common::catalog::{
30 CdcKeyComparison, CdcTableDesc, ColumnCatalog, ColumnDesc, ConflictBehavior,
31 DEFAULT_SCHEMA_NAME, Engine, ICEBERG_SINK_PREFIX, ICEBERG_SOURCE_PREFIX,
32 RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME, TableId,
33};
34use risingwave_common::config::MetaBackend;
35use risingwave_common::global_jvm::Jvm;
36use risingwave_common::session_config::sink_decouple::SinkDecouple;
37use risingwave_common::types::DataType;
38use risingwave_common::util::sort_util::{ColumnOrder, OrderType};
39use risingwave_common::util::value_encoding::DatumToProtoExt;
40use risingwave_common::{bail, bail_not_implemented};
41use risingwave_connector::source::cdc::external::ExternalCdcTableType;
42use risingwave_connector::source::cdc::{
43 build_cdc_table_id, normalize_simple_postgres_quoted_table_name,
44};
45use risingwave_connector::{AUTO_SCHEMA_CHANGE_KEY, WithOptionsSecResolved, WithPropertiesExt};
46use risingwave_pb::catalog::connection::Info as ConnectionInfo;
47use risingwave_pb::catalog::connection_params::ConnectionType;
48use risingwave_pb::catalog::{PbSource, PbWebhookSourceInfo, WatermarkDesc};
49use risingwave_pb::ddl_service::{PbTableJobType, TableJobType};
50use risingwave_pb::plan_common::column_desc::GeneratedOrDefaultColumn;
51use risingwave_pb::plan_common::{
52 AdditionalColumn, ColumnDescVersion, DefaultColumnDesc, GeneratedColumnDesc,
53};
54use risingwave_pb::secret::PbSecretRef;
55use risingwave_pb::secret::secret_ref::PbRefAsType;
56use risingwave_pb::stream_plan::StreamFragmentGraph;
57use risingwave_sqlparser::ast::{
58 CdcTableInfo, ColumnDef, ColumnOption, CompatibleFormatEncode, ConnectionRefValue, CreateSink,
59 CreateSinkStatement, CreateSourceStatement, DataType as AstDataType, ExplainOptions, Format,
60 FormatEncodeOptions, Ident, ObjectName, OnConflict, SecretRefAsType, SourceWatermark,
61 Statement, TableConstraint, WebhookSourceInfo, WithProperties,
62};
63use risingwave_sqlparser::parser::IncludeOption;
64use thiserror_ext::AsReport;
65
66use super::RwPgResponse;
67use super::create_source::{CreateSourceType, SqlColumnStrategy, bind_columns_from_source};
68use crate::binder::{Clause, SecureCompareContext, WEBHOOK_PAYLOAD_FIELD_NAME, bind_data_type};
69use crate::catalog::root_catalog::SchemaPath;
70use crate::catalog::source_catalog::SourceCatalog;
71use crate::catalog::table_catalog::TableVersion;
72use crate::catalog::{ColumnId, DatabaseId, SchemaId, SourceId, check_column_name_not_reserved};
73use crate::error::{ErrorCode, Result, RwError, bail_bind_error};
74use crate::expr::{Expr, ExprImpl, ExprRewriter};
75use crate::handler::HandlerArgs;
76use crate::handler::cdc::{
77 bind_cdc_pk_comparisons_externally, bind_cdc_table_schema, bind_cdc_table_schema_externally,
78 derive_with_options_for_cdc_table, not_null_check_for_cdc_table,
79 reject_pk_filtered_by_debezium_column_filter, sanity_check_for_table_on_cdc_source,
80};
81use crate::handler::create_source::{
82 bind_connector_props, bind_create_source_or_table_with_connector, bind_source_watermark,
83 handle_addition_columns,
84};
85use crate::handler::privilege::ObjectCheckItem;
86use crate::handler::util::{
87 LongRunningNotificationAction, SourceSchemaCompatExt, execute_with_long_running_notification,
88};
89use crate::optimizer::plan_node::generic::{SourceNodeKind, build_cdc_scan_options_with_options};
90use crate::optimizer::plan_node::{
91 LogicalCdcScan, LogicalPlanRef, LogicalSource, StreamPlanRef as PlanRef,
92 ensure_sync_log_store_fragment_root,
93};
94use crate::optimizer::property::{Order, RequiredDist};
95use crate::optimizer::{OptimizerContext, OptimizerContextRef, PlanRoot};
96use crate::session::SessionImpl;
97use crate::session::current::notice_to_user;
98use crate::stream_fragmenter::{GraphJobType, build_graph};
99use crate::utils::OverwriteOptions;
100use crate::{Binder, Explain, TableCatalog, WithOptions};
101
102mod col_id_gen;
103pub use col_id_gen::*;
104use risingwave_connector::sink::SinkParam;
105use risingwave_connector::sink::iceberg::{
106 ENABLE_COMPACTION, ENABLE_MANIFEST_REWRITE, IcebergConfig, IcebergSink,
107 is_iceberg_engine_option, parse_partition_by_exprs, validate_order_key_columns,
108};
109use risingwave_pb::ddl_service::create_iceberg_table_request::{PbSinkJobInfo, PbTableJobInfo};
110
111use crate::handler::create_sink::{SinkPlanContext, gen_sink_plan};
112
113fn ensure_column_options_supported(c: &ColumnDef) -> Result<()> {
114 for option_def in &c.options {
115 match option_def.option {
116 ColumnOption::GeneratedColumns(_) => {}
117 ColumnOption::DefaultValue(_) => {}
118 ColumnOption::DefaultValueInternal { .. } => {}
119 ColumnOption::Unique { is_primary: true } => {}
120 ColumnOption::Null => {}
121 ColumnOption::NotNull => {}
122 _ => bail_not_implemented!("column constraints \"{}\"", option_def),
123 }
124 }
125 Ok(())
126}
127
128pub fn bind_sql_columns(
132 column_defs: &[ColumnDef],
133 is_for_drop_table_connector: bool,
134) -> Result<Vec<ColumnCatalog>> {
135 let mut columns = Vec::with_capacity(column_defs.len());
136
137 for column in column_defs {
138 ensure_column_options_supported(column)?;
139 let ColumnDef {
143 name,
144 data_type,
145 collation,
146 options,
147 ..
148 } = column;
149
150 let data_type = data_type
151 .clone()
152 .ok_or_else(|| ErrorCode::InvalidInputSyntax("data type is not specified".into()))?;
153 if let Some(collation) = collation {
154 if !["C", "POSIX"].contains(&collation.real_value().as_str()) {
160 bail_not_implemented!(
161 "Collate collation other than `C` or `POSIX` is not implemented"
162 );
163 }
164
165 match data_type {
166 AstDataType::Text | AstDataType::Varchar | AstDataType::Char(_) => {}
167 _ => {
168 return Err(ErrorCode::NotSupported(
169 format!("{} is not a collatable data type", data_type),
170 "The only built-in collatable data types are `varchar`, please check your type".into(),
171 ).into());
172 }
173 }
174 }
175
176 if !is_for_drop_table_connector {
177 check_column_name_not_reserved(&name.real_value())?;
181 }
182
183 let nullable: bool = !options
184 .iter()
185 .any(|def| matches!(def.option, ColumnOption::NotNull));
186
187 columns.push(ColumnCatalog {
188 column_desc: ColumnDesc {
189 data_type: bind_data_type(&data_type)?,
190 column_id: ColumnId::placeholder(),
191 name: name.real_value(),
192 generated_or_default_column: None,
193 description: None,
194 additional_column: AdditionalColumn { column_type: None },
195 version: ColumnDescVersion::LATEST,
196 system_column: None,
197 nullable,
198 },
199 is_hidden: false,
200 });
201 }
202
203 Ok(columns)
204}
205
206fn check_generated_column_constraints(
207 column_name: &String,
208 column_id: ColumnId,
209 expr: &ExprImpl,
210 column_catalogs: &[ColumnCatalog],
211 generated_column_names: &[String],
212 pk_column_ids: &[ColumnId],
213) -> Result<()> {
214 let input_refs = expr.collect_input_refs(column_catalogs.len());
215 for idx in input_refs.ones() {
216 let referred_generated_column = &column_catalogs[idx].column_desc.name;
217 if generated_column_names
218 .iter()
219 .any(|c| c == referred_generated_column)
220 {
221 return Err(ErrorCode::BindError(format!(
222 "A generated column cannot reference another generated column. \
223 Generated column \"{}\" references generated column \"{}\"",
224 column_name, referred_generated_column
225 ))
226 .into());
227 }
228 }
229
230 if pk_column_ids.contains(&column_id) && expr.is_impure() {
231 return Err(ErrorCode::BindError(format!(
232 "Generated columns with impure expressions should not be part of the primary key. \
233 Here column \"{}\" is defined as part of the primary key.",
234 column_name
235 ))
236 .into());
237 }
238
239 Ok(())
240}
241
242pub fn bind_sql_column_constraints(
245 session: &SessionImpl,
246 table_name: String,
247 column_catalogs: &mut [ColumnCatalog],
248 columns: &[ColumnDef],
249 pk_column_ids: &[ColumnId],
250) -> Result<()> {
251 let generated_column_names = {
252 let mut names = vec![];
253 for column in columns {
254 for option_def in &column.options {
255 if let ColumnOption::GeneratedColumns(_) = option_def.option {
256 names.push(column.name.real_value());
257 break;
258 }
259 }
260 }
261 names
262 };
263
264 let mut binder = Binder::new_for_ddl(session);
265 binder.bind_columns_to_context(table_name, column_catalogs)?;
266
267 for column in columns {
268 let Some(idx) = column_catalogs
269 .iter()
270 .position(|c| c.name() == column.name.real_value())
271 else {
272 continue;
275 };
276
277 for option_def in &column.options {
278 match &option_def.option {
279 ColumnOption::GeneratedColumns(expr) => {
280 binder.set_clause(Some(Clause::GeneratedColumn));
281
282 let expr_impl = binder.bind_expr(expr).with_context(|| {
283 format!(
284 "failed to bind the expression in generated column \"{}\"",
285 column.name.real_value()
286 )
287 })?;
288
289 check_generated_column_constraints(
290 &column.name.real_value(),
291 column_catalogs[idx].column_id(),
292 &expr_impl,
293 column_catalogs,
294 &generated_column_names,
295 pk_column_ids,
296 )?;
297
298 column_catalogs[idx].column_desc.generated_or_default_column = Some(
299 GeneratedOrDefaultColumn::GeneratedColumn(GeneratedColumnDesc {
300 expr: Some(expr_impl.to_expr_proto()),
301 }),
302 );
303 binder.set_clause(None);
304 }
305 ColumnOption::DefaultValue(expr) => {
306 let expr_impl = binder
307 .bind_expr(expr)?
308 .cast_assign(column_catalogs[idx].data_type())?;
309
310 let rewritten_expr_impl = session
318 .pinned_snapshot()
319 .inline_now_proc_time()
320 .rewrite_expr(expr_impl.clone());
321
322 if let Some(snapshot_value) = rewritten_expr_impl.try_fold_const() {
323 let snapshot_value = snapshot_value?;
324
325 column_catalogs[idx].column_desc.generated_or_default_column =
326 Some(GeneratedOrDefaultColumn::DefaultColumn(DefaultColumnDesc {
327 snapshot_value: Some(snapshot_value.to_protobuf()),
328 expr: Some(expr_impl.to_expr_proto()),
329 }));
331 } else {
332 return Err(ErrorCode::BindError(format!(
333 "Default expression used in column `{}` cannot be evaluated. \
334 Use generated columns instead if you mean to reference other columns.",
335 column.name
336 ))
337 .into());
338 }
339 }
340 ColumnOption::DefaultValueInternal { persisted, expr: _ } => {
341 if persisted.is_empty() {
346 bail_bind_error!(
347 "DEFAULT INTERNAL is only used for internal purposes, \
348 please specify a concrete default value"
349 );
350 }
351
352 let desc = DefaultColumnDesc::decode(&**persisted)
353 .expect("failed to decode persisted `DefaultColumnDesc`");
354
355 column_catalogs[idx].column_desc.generated_or_default_column =
356 Some(GeneratedOrDefaultColumn::DefaultColumn(desc));
357 }
358 _ => {}
359 }
360 }
361 }
362 Ok(())
363}
364
365pub fn bind_table_constraints(table_constraints: &[TableConstraint]) -> Result<Vec<String>> {
367 let mut pk_column_names = vec![];
368
369 for constraint in table_constraints {
370 match constraint {
371 TableConstraint::Unique {
372 name: _,
373 columns,
374 is_primary: true,
375 } => {
376 if !pk_column_names.is_empty() {
377 return Err(multiple_pk_definition_err());
378 }
379 pk_column_names = columns.iter().map(|c| c.real_value()).collect_vec();
380 }
381 _ => bail_not_implemented!("table constraint \"{}\"", constraint),
382 }
383 }
384 Ok(pk_column_names)
385}
386
387pub fn bind_sql_pk_names(
388 columns_defs: &[ColumnDef],
389 pk_names_from_table_constraints: Vec<String>,
390) -> Result<Vec<String>> {
391 let mut pk_column_names = pk_names_from_table_constraints;
392
393 for column in columns_defs {
394 for option_def in &column.options {
395 if let ColumnOption::Unique { is_primary: true } = option_def.option {
396 if !pk_column_names.is_empty() {
397 return Err(multiple_pk_definition_err());
398 }
399 pk_column_names.push(column.name.real_value());
400 };
401 }
402 }
403
404 Ok(pk_column_names)
405}
406
407fn multiple_pk_definition_err() -> RwError {
408 ErrorCode::BindError("multiple primary keys are not allowed".into()).into()
409}
410
411pub fn bind_pk_and_row_id_on_relation(
416 mut columns: Vec<ColumnCatalog>,
417 pk_names: Vec<String>,
418 must_need_pk: bool,
419) -> Result<(Vec<ColumnCatalog>, Vec<ColumnId>, Option<usize>)> {
420 for c in &columns {
421 assert!(c.column_id() != ColumnId::placeholder());
422 }
423
424 let name_to_id = columns
426 .iter()
427 .map(|c| (c.name(), c.column_id()))
428 .collect::<HashMap<_, _>>();
429
430 let mut pk_column_ids: Vec<_> = pk_names
431 .iter()
432 .map(|name| {
433 name_to_id.get(name.as_str()).copied().ok_or_else(|| {
434 ErrorCode::BindError(format!("column \"{name}\" named in key does not exist"))
435 })
436 })
437 .try_collect()?;
438
439 let need_row_id = pk_column_ids.is_empty() && must_need_pk;
441
442 let row_id_index = need_row_id.then(|| {
443 let column = ColumnCatalog::row_id_column();
444 let index = columns.len();
445 pk_column_ids = vec![column.column_id()];
446 columns.push(column);
447 index
448 });
449
450 if let Some(col) = columns.iter().map(|c| c.name()).duplicates().next() {
451 Err(ErrorCode::InvalidInputSyntax(format!(
452 "column \"{col}\" specified more than once"
453 )))?;
454 }
455
456 Ok((columns, pk_column_ids, row_id_index))
457}
458
459#[allow(clippy::too_many_arguments)]
462pub(crate) async fn gen_create_table_plan_with_source(
463 mut handler_args: HandlerArgs,
464 explain_options: ExplainOptions,
465 table_name: ObjectName,
466 column_defs: Vec<ColumnDef>,
467 wildcard_idx: Option<usize>,
468 constraints: Vec<TableConstraint>,
469 format_encode: FormatEncodeOptions,
470 source_watermarks: Vec<SourceWatermark>,
471 mut col_id_gen: ColumnIdGenerator,
472 include_column_options: IncludeOption,
473 props: CreateTableProps,
474 sql_column_strategy: SqlColumnStrategy,
475) -> Result<(PlanRef, Option<SourceCatalog>, TableCatalog)> {
476 if props.append_only
477 && format_encode.format != Format::Plain
478 && format_encode.format != Format::Native
479 {
480 return Err(ErrorCode::BindError(format!(
481 "Append only table does not support format {}.",
482 format_encode.format
483 ))
484 .into());
485 }
486
487 let session = &handler_args.session;
488 let (with_properties, refresh_mode) =
489 bind_connector_props(&handler_args, &format_encode, false)?;
490 if with_properties.is_shareable_cdc_connector() {
491 generated_columns_check_for_cdc_table(&column_defs)?;
492 not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
493 } else if column_defs.iter().any(|col| {
494 col.options
495 .iter()
496 .any(|def| matches!(def.option, ColumnOption::NotNull))
497 }) {
498 notice_to_user(
500 "The table contains columns with NOT NULL constraints. Any rows from upstream violating the constraints will be ignored silently.",
501 );
502 }
503
504 let db_name: &str = &session.database();
505 let (schema_name, _) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
506 session.get_database_and_schema_id_for_create(schema_name.clone())?;
509
510 let (columns_from_resolve_source, source_info) = bind_columns_from_source(
512 session,
513 &format_encode,
514 Either::Left(&with_properties),
515 CreateSourceType::Table,
516 )
517 .await?;
518
519 let overwrite_options = OverwriteOptions::new(&mut handler_args);
520 let rate_limit = overwrite_options.source_rate_limit;
521 let source = bind_create_source_or_table_with_connector(
522 handler_args.clone(),
523 table_name,
524 format_encode,
525 with_properties,
526 &column_defs,
527 constraints,
528 wildcard_idx,
529 source_watermarks,
530 columns_from_resolve_source,
531 source_info,
532 include_column_options,
533 &mut col_id_gen,
534 CreateSourceType::Table,
535 rate_limit,
536 sql_column_strategy,
537 refresh_mode,
538 )
539 .await?;
540
541 let context = OptimizerContext::new(handler_args, explain_options);
542
543 let (plan, table) = gen_table_plan_with_source(
544 context.into(),
545 schema_name,
546 source.clone(),
547 col_id_gen.into_version(),
548 props,
549 )?;
550
551 Ok((plan, Some(source), table))
552}
553
554#[allow(clippy::too_many_arguments)]
557pub(crate) fn gen_create_table_plan(
558 context: OptimizerContext,
559 table_name: ObjectName,
560 column_defs: Vec<ColumnDef>,
561 constraints: Vec<TableConstraint>,
562 mut col_id_gen: ColumnIdGenerator,
563 source_watermarks: Vec<SourceWatermark>,
564 props: CreateTableProps,
565 is_for_replace_plan: bool,
566) -> Result<(PlanRef, TableCatalog)> {
567 let mut columns = bind_sql_columns(&column_defs, is_for_replace_plan)?;
568 for c in &mut columns {
569 col_id_gen.generate(c)?;
570 }
571
572 let (_, secret_refs, connection_refs) = context.with_options().clone().into_parts();
573 if !secret_refs.is_empty() || !connection_refs.is_empty() {
574 return Err(crate::error::ErrorCode::InvalidParameterValue("Secret reference and Connection reference are not allowed in options when creating table without external source".to_owned()).into());
575 }
576
577 gen_create_table_plan_without_source(
578 context,
579 table_name,
580 columns,
581 column_defs,
582 constraints,
583 source_watermarks,
584 col_id_gen.into_version(),
585 props,
586 )
587}
588
589#[allow(clippy::too_many_arguments)]
590pub(crate) fn gen_create_table_plan_without_source(
591 context: OptimizerContext,
592 table_name: ObjectName,
593 columns: Vec<ColumnCatalog>,
594 column_defs: Vec<ColumnDef>,
595 constraints: Vec<TableConstraint>,
596 source_watermarks: Vec<SourceWatermark>,
597 version: TableVersion,
598 props: CreateTableProps,
599) -> Result<(PlanRef, TableCatalog)> {
600 let pk_names = bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
602 let (mut columns, pk_column_ids, row_id_index) =
603 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
604
605 let watermark_descs = bind_source_watermark(
606 context.session_ctx(),
607 table_name.real_value(),
608 source_watermarks,
609 &columns,
610 )?;
611
612 bind_sql_column_constraints(
613 context.session_ctx(),
614 table_name.real_value(),
615 &mut columns,
616 &column_defs,
617 &pk_column_ids,
618 )?;
619 let session = context.session_ctx().clone();
620
621 let db_name = &session.database();
622 let (schema_name, table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
623
624 let info = CreateTableInfo {
625 columns,
626 pk_column_ids,
627 row_id_index,
628 watermark_descs,
629 source_catalog: None,
630 version,
631 };
632
633 gen_table_plan_inner(context.into(), schema_name, table_name, info, props)
634}
635
636fn gen_table_plan_with_source(
637 context: OptimizerContextRef,
638 schema_name: Option<String>,
639 source_catalog: SourceCatalog,
640 version: TableVersion,
641 props: CreateTableProps,
642) -> Result<(PlanRef, TableCatalog)> {
643 let table_name = source_catalog.name.clone();
644
645 let info = CreateTableInfo {
646 columns: source_catalog.columns.clone(),
647 pk_column_ids: source_catalog.pk_col_ids.clone(),
648 row_id_index: source_catalog.row_id_index,
649 watermark_descs: source_catalog.watermark_descs.clone(),
650 source_catalog: Some(source_catalog),
651 version,
652 };
653
654 gen_table_plan_inner(context, schema_name, table_name, info, props)
655}
656
657#[derive(Clone, Copy)]
659pub enum EitherOnConflict {
660 Ast(Option<OnConflict>),
661 Resolved(ConflictBehavior),
662}
663
664impl From<Option<OnConflict>> for EitherOnConflict {
665 fn from(v: Option<OnConflict>) -> Self {
666 Self::Ast(v)
667 }
668}
669
670impl From<ConflictBehavior> for EitherOnConflict {
671 fn from(v: ConflictBehavior) -> Self {
672 Self::Resolved(v)
673 }
674}
675
676impl EitherOnConflict {
677 pub fn to_behavior(self, append_only: bool, row_id_as_pk: bool) -> Result<ConflictBehavior> {
679 let conflict_behavior = match self {
680 EitherOnConflict::Ast(on_conflict) => {
681 if append_only {
682 if row_id_as_pk {
683 ConflictBehavior::NoCheck
685 } else {
686 if let Some(on_conflict) = on_conflict
688 && on_conflict != OnConflict::Nothing
689 {
690 return Err(ErrorCode::InvalidInputSyntax(
691 "When PRIMARY KEY constraint applied to an APPEND ONLY table, \
692 the ON CONFLICT behavior must be DO NOTHING."
693 .to_owned(),
694 )
695 .into());
696 }
697 ConflictBehavior::IgnoreConflict
698 }
699 } else {
700 match on_conflict.unwrap_or(OnConflict::UpdateFull) {
702 OnConflict::UpdateFull => ConflictBehavior::Overwrite,
703 OnConflict::Nothing => ConflictBehavior::IgnoreConflict,
704 OnConflict::UpdateIfNotNull => ConflictBehavior::DoUpdateIfNotNull,
705 }
706 }
707 }
708 EitherOnConflict::Resolved(b) => b,
709 };
710
711 Ok(conflict_behavior)
712 }
713}
714
715pub struct CreateTableInfo {
720 pub columns: Vec<ColumnCatalog>,
721 pub pk_column_ids: Vec<ColumnId>,
722 pub row_id_index: Option<usize>,
723 pub watermark_descs: Vec<WatermarkDesc>,
724 pub source_catalog: Option<SourceCatalog>,
725 pub version: TableVersion,
726}
727
728pub struct CreateTableProps {
733 pub definition: String,
734 pub append_only: bool,
735 pub on_conflict: EitherOnConflict,
736 pub with_version_columns: Vec<String>,
737 pub webhook_info: Option<PbWebhookSourceInfo>,
738 pub engine: Engine,
739}
740
741#[allow(clippy::too_many_arguments)]
742fn gen_table_plan_inner(
743 context: OptimizerContextRef,
744 schema_name: Option<String>,
745 table_name: String,
746 info: CreateTableInfo,
747 props: CreateTableProps,
748) -> Result<(PlanRef, TableCatalog)> {
749 let CreateTableInfo {
750 ref columns,
751 row_id_index,
752 ref watermark_descs,
753 ref source_catalog,
754 ..
755 } = info;
756 let CreateTableProps { append_only, .. } = props;
757
758 let (database_id, schema_id) = context
759 .session_ctx()
760 .get_database_and_schema_id_for_create(schema_name)?;
761
762 let session = context.session_ctx().clone();
763 let retention_seconds = context.with_options().retention_seconds();
764
765 let source_node: LogicalPlanRef = LogicalSource::new(
766 source_catalog.clone().map(Rc::new),
767 columns.clone(),
768 row_id_index,
769 SourceNodeKind::CreateTable,
770 context.clone(),
771 None,
772 )?
773 .into();
774
775 let required_cols = FixedBitSet::with_capacity(columns.len());
776 let plan_root = PlanRoot::new_with_logical_plan(
777 source_node,
778 RequiredDist::Any,
779 Order::any(),
780 required_cols,
781 vec![],
782 );
783
784 let has_non_ttl_watermark = watermark_descs.iter().any(|d| !d.with_ttl);
785
786 if !append_only && has_non_ttl_watermark {
787 return Err(ErrorCode::NotSupported(
788 "Defining watermarks on table requires the table to be append only.".to_owned(),
789 "Use the key words `APPEND ONLY`".to_owned(),
790 )
791 .into());
792 }
793
794 if !append_only && retention_seconds.is_some() {
795 if session
796 .config()
797 .unsafe_enable_storage_retention_for_non_append_only_tables()
798 {
799 tracing::warn!(
800 "Storage retention is enabled for non-append-only table {}. This may lead to stream inconsistency.",
801 table_name
802 );
803 const NOTICE: &str = "Storage retention is enabled for non-append-only table. \
804 This may lead to stream inconsistency and unrecoverable \
805 node failure if there is any row INSERT/UPDATE/DELETE operation \
806 corresponding to the TTLed primary keys";
807 session.notice_to_user(NOTICE);
808 } else {
809 return Err(ErrorCode::NotSupported(
810 "Defining retention seconds on table requires the table to be append only."
811 .to_owned(),
812 "Use the key words `APPEND ONLY`".to_owned(),
813 )
814 .into());
815 }
816 }
817
818 let materialize =
819 plan_root.gen_table_plan(context, table_name, database_id, schema_id, info, props)?;
820
821 let mut table = materialize.table().clone();
822 table.owner = session.user_id();
823
824 Ok((
825 ensure_sync_log_store_fragment_root(materialize.into()),
826 table,
827 ))
828}
829
830#[allow(clippy::too_many_arguments)]
834pub(crate) fn gen_create_table_plan_for_cdc_table(
835 context: OptimizerContextRef,
836 source: Arc<SourceCatalog>,
837 external_table_name: String,
838 column_defs: Vec<ColumnDef>,
839 source_watermarks: Vec<SourceWatermark>,
840 mut columns: Vec<ColumnCatalog>,
841 pk_names: Vec<String>,
842 pk_comparisons: Vec<CdcKeyComparison>,
843 cdc_with_options: WithOptionsSecResolved,
844 mut col_id_gen: ColumnIdGenerator,
845 on_conflict: Option<OnConflict>,
846 with_version_columns: Vec<String>,
847 include_column_options: IncludeOption,
848 table_name: ObjectName,
849 resolved_table_name: String, database_id: DatabaseId,
851 schema_id: SchemaId,
852 table_id: TableId,
853 engine: Engine,
854) -> Result<(PlanRef, TableCatalog)> {
855 let session = context.session_ctx().clone();
856
857 handle_addition_columns(
859 None,
860 &cdc_with_options,
861 include_column_options,
862 &mut columns,
863 true,
864 )?;
865
866 for c in &mut columns {
867 col_id_gen.generate(c)?;
868 }
869
870 let (mut columns, pk_column_ids, _row_id_index) =
871 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
872
873 let watermark_descs = bind_source_watermark(
874 context.session_ctx(),
875 table_name.real_value(),
876 source_watermarks,
877 &columns,
878 )?;
879
880 bind_sql_column_constraints(
882 context.session_ctx(),
883 table_name.real_value(),
884 &mut columns,
885 &column_defs,
886 &pk_column_ids,
887 )?;
888
889 let definition = context.normalized_sql().to_owned();
890
891 let pk_column_indices = {
892 let mut id_to_idx = HashMap::new();
893 columns.iter().enumerate().for_each(|(idx, c)| {
894 id_to_idx.insert(c.column_id(), idx);
895 });
896 pk_column_ids
898 .iter()
899 .map(|c| id_to_idx.get(c).copied().unwrap())
900 .collect_vec()
901 };
902 let table_pk = pk_column_indices
903 .iter()
904 .map(|idx| ColumnOrder::new(*idx, OrderType::ascending()))
905 .collect();
906
907 let (options, secret_refs) = cdc_with_options.into_parts();
908
909 let non_generated_column_descs = columns
910 .iter()
911 .filter(|&c| !c.is_generated())
912 .map(|c| c.column_desc.clone())
913 .collect_vec();
914 let non_generated_column_num = non_generated_column_descs.len();
915 let cdc_table_type = ExternalCdcTableType::from_properties(&options);
916 let cdc_table_desc = CdcTableDesc {
917 table_id,
918 source_id: source.id, external_table_name: external_table_name.clone(),
920 pk: table_pk,
921 pk_comparisons,
922 columns: non_generated_column_descs,
923 stream_key: pk_column_indices,
924 connect_properties: options,
925 secret_refs,
926 };
927
928 tracing::debug!(?cdc_table_desc, "create cdc table");
929 let options = build_cdc_scan_options_with_options(context.with_options(), &cdc_table_type)?;
930
931 let logical_scan = LogicalCdcScan::create(
932 external_table_name.clone(),
933 Rc::new(cdc_table_desc),
934 context.clone(),
935 options,
936 );
937
938 let scan_node: LogicalPlanRef = logical_scan.into();
939 let required_cols = FixedBitSet::with_capacity(non_generated_column_num);
940 let plan_root = PlanRoot::new_with_logical_plan(
941 scan_node,
942 RequiredDist::Any,
943 Order::any(),
944 required_cols,
945 vec![],
946 );
947
948 let cdc_table_id_external_table_name = if let ExternalCdcTableType::Postgres = cdc_table_type
949 && let Some(normalized_table_name) =
950 normalize_simple_postgres_quoted_table_name(&external_table_name)
951 {
952 normalized_table_name
953 } else {
954 external_table_name
955 };
956 let cdc_table_id = build_cdc_table_id(source.id, &cdc_table_id_external_table_name);
957 let materialize = plan_root.gen_table_plan(
958 context,
959 resolved_table_name,
960 database_id,
961 schema_id,
962 CreateTableInfo {
963 columns,
964 pk_column_ids,
965 row_id_index: None,
966 watermark_descs,
967 source_catalog: Some((*source).clone()),
968 version: col_id_gen.into_version(),
969 },
970 CreateTableProps {
971 definition,
972 append_only: false,
973 on_conflict: on_conflict.into(),
974 with_version_columns,
975 webhook_info: None,
976 engine,
977 },
978 )?;
979
980 let mut table = materialize.table().clone();
981 table.owner = session.user_id();
982 table.cdc_table_id = Some(cdc_table_id);
983 table.cdc_table_type = Some(cdc_table_type);
984 Ok((
985 ensure_sync_log_store_fragment_root(materialize.into()),
986 table,
987 ))
988}
989
990#[allow(clippy::too_many_arguments)]
991pub(super) async fn handle_create_table_plan(
992 handler_args: HandlerArgs,
993 explain_options: ExplainOptions,
994 format_encode: Option<FormatEncodeOptions>,
995 cdc_table_info: Option<CdcTableInfo>,
996 table_name: &ObjectName,
997 column_defs: Vec<ColumnDef>,
998 wildcard_idx: Option<usize>,
999 constraints: Vec<TableConstraint>,
1000 source_watermarks: Vec<SourceWatermark>,
1001 append_only: bool,
1002 on_conflict: Option<OnConflict>,
1003 with_version_columns: Vec<String>,
1004 include_column_options: IncludeOption,
1005 webhook_info: Option<WebhookSourceInfo>,
1006 engine: Engine,
1007) -> Result<(
1008 PlanRef,
1009 Option<SourceCatalog>,
1010 TableCatalog,
1011 TableJobType,
1012 Option<SourceId>,
1013)> {
1014 let col_id_gen = ColumnIdGenerator::new_initial();
1015 let format_encode = check_create_table_with_source(
1016 &handler_args.with_options,
1017 format_encode,
1018 &include_column_options,
1019 &cdc_table_info,
1020 )?;
1021 let webhook_info = webhook_info
1022 .map(|info| bind_webhook_info(&handler_args.session, &column_defs, info))
1023 .transpose()?;
1024
1025 let props = CreateTableProps {
1026 definition: handler_args.normalized_sql.clone(),
1027 append_only,
1028 on_conflict: on_conflict.into(),
1029 with_version_columns: with_version_columns.clone(),
1030 webhook_info,
1031 engine,
1032 };
1033
1034 let ((plan, source, table), job_type, shared_shource_id) = match (
1035 format_encode,
1036 cdc_table_info.as_ref(),
1037 ) {
1038 (Some(format_encode), None) => (
1039 gen_create_table_plan_with_source(
1040 handler_args,
1041 explain_options,
1042 table_name.clone(),
1043 column_defs,
1044 wildcard_idx,
1045 constraints,
1046 format_encode,
1047 source_watermarks,
1048 col_id_gen,
1049 include_column_options,
1050 props,
1051 SqlColumnStrategy::FollowChecked,
1052 )
1053 .await?,
1054 TableJobType::General,
1055 None,
1056 ),
1057 (None, None) => {
1058 let context = OptimizerContext::new(handler_args, explain_options);
1059 let (plan, table) = gen_create_table_plan(
1060 context,
1061 table_name.clone(),
1062 column_defs,
1063 constraints,
1064 col_id_gen,
1065 source_watermarks,
1066 props,
1067 false,
1068 )?;
1069
1070 ((plan, None, table), TableJobType::General, None)
1071 }
1072
1073 (None, Some(cdc_table)) => {
1074 sanity_check_for_table_on_cdc_source(
1075 append_only,
1076 &column_defs,
1077 &wildcard_idx,
1078 &constraints,
1079 &source_watermarks,
1080 )?;
1081
1082 generated_columns_check_for_cdc_table(&column_defs)?;
1083 not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
1084
1085 let session = &handler_args.session;
1086 let db_name = &session.database();
1087 let user_name = &session.user_name();
1088 let search_path = session.config().search_path();
1089 let (schema_name, resolved_table_name) =
1090 Binder::resolve_schema_qualified_name(db_name, table_name)?;
1091 let (database_id, schema_id) =
1092 session.get_database_and_schema_id_for_create(schema_name.clone())?;
1093
1094 let (source_schema, source_name) =
1096 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
1097
1098 let source = {
1099 let catalog_reader = session.env().catalog_reader().read_guard();
1100 let schema_path =
1101 SchemaPath::new(source_schema.as_deref(), &search_path, user_name);
1102
1103 let (source, _) = catalog_reader.get_source_by_name(
1104 db_name,
1105 schema_path,
1106 source_name.as_str(),
1107 )?;
1108 source.clone()
1109 };
1110 check_cdc_source_select_privilege(session, &source)?;
1111 let (cdc_with_options, normalized_external_table_name) =
1112 derive_with_options_for_cdc_table(
1113 &source.with_properties,
1114 cdc_table.external_table_name.clone(),
1115 )?;
1116
1117 let (columns, pk_names, pk_comparisons) = match wildcard_idx {
1118 Some(_) => bind_cdc_table_schema_externally(cdc_with_options.clone()).await?,
1119 None => {
1120 for column_def in &column_defs {
1121 for option_def in &column_def.options {
1122 if let ColumnOption::DefaultValue(_)
1123 | ColumnOption::DefaultValueInternal { .. } = option_def.option
1124 {
1125 return Err(ErrorCode::NotSupported(
1126 "Default value for columns defined on the table created from a CDC source".into(),
1127 "Remove the default value expression in the column definitions".into(),
1128 )
1129 .into());
1130 }
1131 }
1132 }
1133
1134 let (columns, pk_names) =
1135 bind_cdc_table_schema(&column_defs, &constraints, false)?;
1136 let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1137 cdc_with_options.clone(),
1138 &pk_names,
1139 ))
1140 .await?;
1141
1142 (columns, pk_names, pk_comparisons)
1143 }
1144 };
1145
1146 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1151
1152 let context: OptimizerContextRef =
1153 OptimizerContext::new(handler_args, explain_options).into();
1154 let shared_source_id = source.id;
1155 let (plan, table) = gen_create_table_plan_for_cdc_table(
1156 context,
1157 source,
1158 normalized_external_table_name,
1159 column_defs,
1160 source_watermarks,
1161 columns,
1162 pk_names,
1163 pk_comparisons,
1164 cdc_with_options,
1165 col_id_gen,
1166 on_conflict,
1167 with_version_columns,
1168 include_column_options,
1169 table_name.clone(),
1170 resolved_table_name,
1171 database_id,
1172 schema_id,
1173 TableId::placeholder(),
1174 engine,
1175 )?;
1176
1177 (
1178 (plan, None, table),
1179 TableJobType::SharedCdcSource,
1180 Some(shared_source_id),
1181 )
1182 }
1183 (Some(_), Some(_)) => {
1184 return Err(ErrorCode::NotSupported(
1185 "Data format and encoding format doesn't apply to table created from a CDC source"
1186 .into(),
1187 "Remove the FORMAT and ENCODE specification".into(),
1188 )
1189 .into());
1190 }
1191 };
1192 Ok((plan, source, table, job_type, shared_shource_id))
1193}
1194
1195fn generated_columns_check_for_cdc_table(columns: &Vec<ColumnDef>) -> Result<()> {
1197 let mut found_generated_column = false;
1198 for column in columns {
1199 let mut is_generated = false;
1200
1201 for option_def in &column.options {
1202 if let ColumnOption::GeneratedColumns(_) = option_def.option {
1203 is_generated = true;
1204 break;
1205 }
1206 }
1207
1208 if is_generated {
1209 found_generated_column = true;
1210 } else if found_generated_column {
1211 return Err(ErrorCode::NotSupported(
1212 "Non-generated column found after a generated column.".into(),
1213 "Ensure that all generated columns appear at the end of the cdc table definition."
1214 .into(),
1215 )
1216 .into());
1217 }
1218 }
1219 Ok(())
1220}
1221
1222#[allow(clippy::too_many_arguments)]
1223pub async fn handle_create_table(
1224 handler_args: HandlerArgs,
1225 table_name: ObjectName,
1226 column_defs: Vec<ColumnDef>,
1227 wildcard_idx: Option<usize>,
1228 constraints: Vec<TableConstraint>,
1229 if_not_exists: bool,
1230 format_encode: Option<FormatEncodeOptions>,
1231 source_watermarks: Vec<SourceWatermark>,
1232 append_only: bool,
1233 on_conflict: Option<OnConflict>,
1234 with_version_columns: Vec<String>,
1235 cdc_table_info: Option<CdcTableInfo>,
1236 include_column_options: IncludeOption,
1237 webhook_info: Option<WebhookSourceInfo>,
1238 ast_engine: risingwave_sqlparser::ast::Engine,
1239) -> Result<RwPgResponse> {
1240 let session = handler_args.session.clone();
1241
1242 if append_only {
1243 session.notice_to_user("APPEND ONLY TABLE is currently an experimental feature.");
1244 }
1245
1246 session.check_cluster_limits().await?;
1247
1248 let engine = match ast_engine {
1249 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1250 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1251 };
1252
1253 if let Either::Right(resp) = session.check_relation_name_duplicated(
1254 table_name.clone(),
1255 StatementType::CREATE_TABLE,
1256 if_not_exists,
1257 )? {
1258 return Ok(resp);
1259 }
1260
1261 let (graph, source, hummock_table, job_type, shared_source_id) = {
1262 let (plan, source, table, job_type, shared_source_id) = Box::pin(handle_create_table_plan(
1263 handler_args.clone(),
1264 ExplainOptions::default(),
1265 format_encode,
1266 cdc_table_info,
1267 &table_name,
1268 column_defs.clone(),
1269 wildcard_idx,
1270 constraints.clone(),
1271 source_watermarks,
1272 append_only,
1273 on_conflict,
1274 with_version_columns,
1275 include_column_options,
1276 webhook_info,
1277 engine,
1278 ))
1279 .await?;
1280 tracing::trace!("table_plan: {:?}", plan.explain_to_string());
1281
1282 let graph = build_graph(plan, Some(GraphJobType::Table))?;
1283
1284 (graph, source, table, job_type, shared_source_id)
1285 };
1286
1287 tracing::trace!(
1288 "name={}, graph=\n{}",
1289 table_name,
1290 serde_json::to_string_pretty(&graph).unwrap()
1291 );
1292
1293 let dependencies = shared_source_id
1294 .map(|id| HashSet::from([id.as_object_id()]))
1295 .unwrap_or_default();
1296
1297 match engine {
1299 Engine::Hummock => {
1300 let catalog_writer = session.catalog_writer()?;
1301 let action = match job_type {
1302 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1303 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1304 };
1305 execute_with_long_running_notification(
1306 catalog_writer.create_table(
1307 source.map(|s| s.to_prost()),
1308 hummock_table.to_prost(),
1309 graph,
1310 job_type,
1311 if_not_exists,
1312 dependencies,
1313 ),
1314 &session,
1315 "CREATE TABLE",
1316 action,
1317 )
1318 .await?;
1319 }
1320 Engine::Iceberg => {
1321 let hummock_table_name = hummock_table.name.clone();
1322 session.create_staging_table(hummock_table.clone());
1323 let res = Box::pin(create_iceberg_engine_table(
1324 session.clone(),
1325 handler_args,
1326 source.map(|s| s.to_prost()),
1327 hummock_table,
1328 graph,
1329 table_name,
1330 job_type,
1331 if_not_exists,
1332 ))
1333 .await;
1334 session.drop_staging_table(&hummock_table_name);
1335 res?
1336 }
1337 }
1338
1339 Ok(PgResponse::empty_result(StatementType::CREATE_TABLE))
1340}
1341
1342fn build_iceberg_engine_sink_options(
1343 mut sink_options: BTreeMap<String, String>,
1344 user_options: &WithOptions,
1345 table: &TableCatalog,
1346 primary_key: &[String],
1347) -> Result<BTreeMap<String, String>> {
1348 sink_options.extend(
1349 user_options
1350 .iter()
1351 .filter(|(key, _)| is_iceberg_engine_option(key))
1352 .map(|(key, value)| (key.clone(), value.clone())),
1353 );
1354
1355 sink_options
1356 .entry(ENABLE_COMPACTION.to_owned())
1357 .or_insert_with(|| "true".to_owned());
1358 sink_options.insert(
1359 "type".to_owned(),
1360 if table.append_only {
1361 "append-only"
1362 } else {
1363 "upsert"
1364 }
1365 .to_owned(),
1366 );
1367
1368 if !table.append_only {
1371 sink_options.insert("primary_key".to_owned(), primary_key.join(","));
1372 }
1373
1374 sink_options.insert("create_table_if_not_exists".to_owned(), "true".to_owned());
1375 sink_options.insert("is_exactly_once".to_owned(), "true".to_owned());
1376
1377 let config = IcebergConfig::from_btreemap(sink_options.clone())?;
1378
1379 if !table.append_only && !config.enable_pk_index {
1382 for pk in table.pk() {
1383 let column = &table.columns()[pk.column_index];
1384 let data_type = column.data_type();
1385 if data_type.is_composite()
1386 || matches!(data_type, DataType::Float32 | DataType::Float64)
1387 {
1388 return Err(ErrorCode::NotSupported(
1389 format!(
1390 "Iceberg engine table primary key column \"{}\" has unsupported type {} for equality deletes",
1391 column.name(), data_type
1392 ),
1393 "Use non-floating-point scalar primary key columns.".to_owned(),
1394 )
1395 .into());
1396 }
1397 }
1398 }
1399
1400 if config.table_format_version() < FormatVersion::V3 {
1403 sink_options
1404 .entry(ENABLE_MANIFEST_REWRITE.to_owned())
1405 .or_insert_with(|| "true".to_owned());
1406 }
1407
1408 if let Some(partition_by) = &config.partition_by {
1409 let mut partition_columns = vec![];
1410 for (column, _) in parse_partition_by_exprs(partition_by.clone())? {
1411 table
1412 .columns()
1413 .iter()
1414 .find(|col| col.name().eq_ignore_ascii_case(&column))
1415 .ok_or_else(|| {
1416 ErrorCode::InvalidInputSyntax(format!(
1417 "Partition source column does not exist in schema: {}",
1418 column
1419 ))
1420 })?;
1421
1422 partition_columns.push(column);
1423 }
1424
1425 ensure_partition_columns_are_prefix_of_primary_key(&partition_columns, primary_key)
1426 .map_err(|_| {
1427 ErrorCode::InvalidInputSyntax(
1428 "The partition columns should be the prefix of the primary key".to_owned(),
1429 )
1430 })?;
1431 }
1432
1433 if let Some(order_key) = &config.order_key {
1434 validate_order_key_columns(order_key, table.columns().iter().map(|col| col.name()))
1435 .map_err(|err| ErrorCode::InvalidInputSyntax(err.to_report_string()))?;
1436 }
1437
1438 if config.enable_pk_index {
1439 sink_options.remove("primary_key");
1440 } else {
1441 sink_options.insert(AUTO_SCHEMA_CHANGE_KEY.to_owned(), "true".to_owned());
1442 }
1443
1444 Ok(sink_options)
1445}
1446
1447#[allow(clippy::too_many_arguments)]
1456pub async fn create_iceberg_engine_table(
1457 session: Arc<SessionImpl>,
1458 handler_args: HandlerArgs,
1459 mut source: Option<PbSource>,
1460 table: TableCatalog,
1461 graph: StreamFragmentGraph,
1462 table_name: ObjectName,
1463 job_type: PbTableJobType,
1464 if_not_exists: bool,
1465) -> Result<()> {
1466 let rw_db_name = session
1467 .env()
1468 .catalog_reader()
1469 .read_guard()
1470 .get_database_by_id(table.database_id)?
1471 .name()
1472 .to_owned();
1473 let rw_schema_name = session
1474 .env()
1475 .catalog_reader()
1476 .read_guard()
1477 .get_schema_by_id(table.database_id, table.schema_id)?
1478 .name()
1479 .clone();
1480 let iceberg_catalog_name = rw_db_name.clone();
1481 let iceberg_database_name = rw_schema_name.clone();
1482 let iceberg_table_name = table_name.0.last().unwrap().real_value();
1483
1484 let iceberg_engine_connection: String = session.config().iceberg_engine_connection();
1485 let sink_decouple = session.config().sink_decouple();
1486 if matches!(sink_decouple, SinkDecouple::Disable) {
1487 bail!(
1488 "Iceberg engine table only supports with sink decouple, try `set sink_decouple = true` to resolve it"
1489 );
1490 }
1491
1492 let mut connection_ref = BTreeMap::new();
1493 let with_common = if iceberg_engine_connection.is_empty() {
1494 bail!("to use iceberg engine table, the variable `iceberg_engine_connection` must be set.");
1495 } else {
1496 let parts: Vec<&str> = iceberg_engine_connection.split('.').collect();
1497 assert_eq!(parts.len(), 2);
1498 let connection_catalog =
1499 session.get_connection_by_name(Some(parts[0].to_owned()), parts[1])?;
1500 if let ConnectionInfo::ConnectionParams(params) = &connection_catalog.info {
1501 if params.connection_type == ConnectionType::Iceberg as i32 {
1502 connection_ref.insert(
1504 "connection".to_owned(),
1505 ConnectionRefValue {
1506 connection_name: ObjectName::from(vec![
1507 Ident::from(parts[0]),
1508 Ident::from(parts[1]),
1509 ]),
1510 },
1511 );
1512
1513 let mut with_common = BTreeMap::new();
1514 with_common.insert("connector".to_owned(), "iceberg".to_owned());
1515 with_common.insert("database.name".to_owned(), iceberg_database_name);
1516 with_common.insert("table.name".to_owned(), iceberg_table_name);
1517
1518 let hosted_catalog = params
1519 .properties
1520 .get("hosted_catalog")
1521 .map(|s| s.eq_ignore_ascii_case("true"))
1522 .unwrap_or(false);
1523 if hosted_catalog {
1524 let meta_client = session.env().meta_client();
1525 let meta_store_endpoint = meta_client.get_meta_store_endpoint().await?;
1526
1527 let meta_store_endpoint =
1528 url::Url::parse(&meta_store_endpoint).map_err(|_| {
1529 ErrorCode::InternalError(
1530 "failed to parse the meta store endpoint".to_owned(),
1531 )
1532 })?;
1533 let meta_store_backend = meta_store_endpoint.scheme().to_owned();
1534 let meta_store_user = meta_store_endpoint.username().to_owned();
1535 let meta_store_password = match meta_store_endpoint.password() {
1536 Some(password) => percent_decode_str(password)
1537 .decode_utf8()
1538 .map_err(|_| {
1539 ErrorCode::InternalError(
1540 "failed to parse password from meta store endpoint".to_owned(),
1541 )
1542 })?
1543 .into_owned(),
1544 None => "".to_owned(),
1545 };
1546 let meta_store_host = meta_store_endpoint
1547 .host_str()
1548 .ok_or_else(|| {
1549 ErrorCode::InternalError(
1550 "failed to parse host from meta store endpoint".to_owned(),
1551 )
1552 })?
1553 .to_owned();
1554 let meta_store_port = meta_store_endpoint.port().ok_or_else(|| {
1555 ErrorCode::InternalError(
1556 "failed to parse port from meta store endpoint".to_owned(),
1557 )
1558 })?;
1559 let meta_store_database = meta_store_endpoint
1560 .path()
1561 .trim_start_matches('/')
1562 .to_owned();
1563
1564 let Ok(meta_backend) = MetaBackend::from_str(&meta_store_backend, true) else {
1565 bail!("failed to parse meta backend: {}", meta_store_backend);
1566 };
1567
1568 let catalog_uri = match meta_backend {
1569 MetaBackend::Postgres => {
1570 format!(
1571 "jdbc:postgresql://{}:{}/{}",
1572 meta_store_host, meta_store_port, meta_store_database
1573 )
1574 }
1575 MetaBackend::Mysql => {
1576 format!(
1577 "jdbc:mysql://{}:{}/{}",
1578 meta_store_host, meta_store_port, meta_store_database
1579 )
1580 }
1581 MetaBackend::Sqlite | MetaBackend::Sql | MetaBackend::Mem => {
1582 bail!(
1583 "Unsupported meta backend for iceberg engine table: {}",
1584 meta_store_backend
1585 );
1586 }
1587 };
1588
1589 with_common.insert("catalog.type".to_owned(), "jdbc".to_owned());
1590 with_common.insert("catalog.uri".to_owned(), catalog_uri);
1591 with_common.insert("catalog.jdbc.user".to_owned(), meta_store_user);
1592 with_common.insert("catalog.jdbc.password".to_owned(), meta_store_password);
1593 with_common.insert("catalog.name".to_owned(), iceberg_catalog_name);
1594 }
1595
1596 with_common
1597 } else {
1598 return Err(RwError::from(ErrorCode::InvalidParameterValue(
1599 "Only iceberg connection could be used in iceberg engine".to_owned(),
1600 )));
1601 }
1602 } else {
1603 return Err(RwError::from(ErrorCode::InvalidParameterValue(
1604 "Private Link Service has been deprecated. Please create a new connection instead."
1605 .to_owned(),
1606 )));
1607 }
1608 };
1609
1610 let mut pks = table
1613 .pk_column_names()
1614 .iter()
1615 .map(|c| c.to_string())
1616 .collect::<Vec<String>>();
1617
1618 if pks.len() == 1 && pks[0].eq(ROW_ID_COLUMN_NAME) {
1620 pks = vec![RISINGWAVE_ICEBERG_ROW_ID.to_owned()];
1621 }
1622
1623 let sink_from = CreateSink::From(table_name.clone());
1624
1625 let mut sink_name = table_name.clone();
1626 *sink_name.0.last_mut().unwrap() = Ident::from(
1627 (ICEBERG_SINK_PREFIX.to_owned() + &sink_name.0.last().unwrap().real_value()).as_str(),
1628 );
1629 let create_sink_stmt = CreateSinkStatement {
1630 or_replace: false,
1631 if_not_exists: false,
1632 sink_name,
1633 with_properties: WithProperties(vec![]),
1634 sink_from,
1635 columns: vec![],
1636 emit_mode: None,
1637 sink_schema: None,
1638 into_table_name: None,
1639 };
1640
1641 let mut sink_handler_args = handler_args.clone();
1642
1643 let sink_with = build_iceberg_engine_sink_options(
1644 with_common.clone(),
1645 &handler_args.with_options,
1646 &table,
1647 &pks,
1648 )?;
1649
1650 if let Some(source) = source.as_mut() {
1651 source
1652 .with_properties
1653 .retain(|key, _| !is_iceberg_engine_option(key));
1654 }
1655
1656 sink_handler_args.with_options =
1676 WithOptions::new(sink_with, Default::default(), connection_ref.clone());
1677 let SinkPlanContext {
1678 sink_plan,
1679 sink_catalog,
1680 ..
1681 } = gen_sink_plan(sink_handler_args, create_sink_stmt, None, true).await?;
1682 let sink_graph = build_graph(sink_plan, Some(GraphJobType::Sink))?;
1683
1684 let mut source_name = table_name.clone();
1685 *source_name.0.last_mut().unwrap() = Ident::from(
1686 (ICEBERG_SOURCE_PREFIX.to_owned() + &source_name.0.last().unwrap().real_value()).as_str(),
1687 );
1688 let create_source_stmt = CreateSourceStatement {
1689 temporary: false,
1690 if_not_exists: false,
1691 columns: vec![],
1692 source_name,
1693 wildcard_idx: Some(0),
1694 constraints: vec![],
1695 with_properties: WithProperties(vec![]),
1696 format_encode: CompatibleFormatEncode::V2(FormatEncodeOptions::none()),
1697 source_watermarks: vec![],
1698 include_column_options: vec![],
1699 cdc_table_info: None,
1700 };
1701
1702 let mut source_handler_args = handler_args.clone();
1703 let source_with = with_common;
1704 source_handler_args.with_options =
1705 WithOptions::new(source_with, Default::default(), connection_ref);
1706
1707 let overwrite_options = OverwriteOptions::new(&mut source_handler_args);
1708 let format_encode = create_source_stmt.format_encode.into_v2_with_warning();
1709 let (with_properties, refresh_mode) =
1710 bind_connector_props(&source_handler_args, &format_encode, true)?;
1711
1712 let (iceberg_catalog, table_identifier, table_created) = {
1715 let sink_param = SinkParam::try_from_sink_catalog(sink_catalog.clone())?;
1716 let iceberg_sink = IcebergSink::try_from(sink_param)?;
1717 let table_created = iceberg_sink.create_table_if_not_exists().await?;
1718
1719 let iceberg_catalog = iceberg_sink.config.create_catalog().await?;
1720 let table_identifier = iceberg_sink.config.full_table_name()?;
1721 (iceberg_catalog, table_identifier, table_created)
1722 };
1723
1724 let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
1725 let (columns_from_resolve_source, source_info) = bind_columns_from_source(
1726 &session,
1727 &format_encode,
1728 Either::Left(&with_properties),
1729 create_source_type,
1730 )
1731 .await?;
1732 let mut col_id_gen = ColumnIdGenerator::new_initial();
1733
1734 let iceberg_source_catalog = bind_create_source_or_table_with_connector(
1735 source_handler_args,
1736 create_source_stmt.source_name,
1737 format_encode,
1738 with_properties,
1739 &create_source_stmt.columns,
1740 create_source_stmt.constraints,
1741 create_source_stmt.wildcard_idx,
1742 create_source_stmt.source_watermarks,
1743 columns_from_resolve_source,
1744 source_info,
1745 create_source_stmt.include_column_options,
1746 &mut col_id_gen,
1747 create_source_type,
1748 overwrite_options.source_rate_limit,
1749 SqlColumnStrategy::FollowChecked,
1750 refresh_mode,
1751 )
1752 .await?;
1753
1754 let _ = Jvm::get_or_init()?;
1757
1758 let catalog_writer = session.catalog_writer()?;
1759 let action = match job_type {
1760 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1761 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1762 };
1763 let res = execute_with_long_running_notification(
1764 catalog_writer.create_iceberg_table(
1765 PbTableJobInfo {
1766 source,
1767 table: Some(table.to_prost()),
1768 fragment_graph: Some(graph),
1769 job_type: job_type as _,
1770 },
1771 PbSinkJobInfo {
1772 sink: Some(sink_catalog.to_proto()),
1773 fragment_graph: Some(sink_graph),
1774 },
1775 iceberg_source_catalog.to_prost(),
1776 if_not_exists,
1777 ),
1778 &session,
1779 "CREATE TABLE",
1780 action,
1781 )
1782 .await;
1783
1784 if res.is_err() {
1785 if table_created {
1788 let _ = iceberg_catalog
1789 .drop_table(&table_identifier)
1790 .await
1791 .inspect_err(|err| {
1792 tracing::error!(
1793 "failed to drop iceberg table {} after create iceberg engine table failed: {}",
1794 table_identifier,
1795 err.as_report()
1796 );
1797 });
1798 }
1799 res?
1800 }
1801
1802 Ok(())
1803}
1804
1805pub fn check_create_table_with_source(
1806 with_options: &WithOptions,
1807 format_encode: Option<FormatEncodeOptions>,
1808 include_column_options: &IncludeOption,
1809 cdc_table_info: &Option<CdcTableInfo>,
1810) -> Result<Option<FormatEncodeOptions>> {
1811 if cdc_table_info.is_some() {
1813 return Ok(format_encode);
1814 }
1815 let defined_source = with_options.is_source_connector();
1816
1817 if !include_column_options.is_empty() && !defined_source {
1818 return Err(ErrorCode::InvalidInputSyntax(
1819 "INCLUDE should be used with a connector".to_owned(),
1820 )
1821 .into());
1822 }
1823 if defined_source {
1824 format_encode.as_ref().ok_or_else(|| {
1825 ErrorCode::InvalidInputSyntax("Please specify a source schema using FORMAT".to_owned())
1826 })?;
1827 }
1828 Ok(format_encode)
1829}
1830
1831fn ensure_partition_columns_are_prefix_of_primary_key(
1832 partition_columns: &[String],
1833 primary_key_columns: &[String],
1834) -> std::result::Result<(), String> {
1835 if partition_columns.len() > primary_key_columns.len() {
1836 return Err("Partition columns cannot be longer than primary key columns.".to_owned());
1837 }
1838
1839 for (i, partition_col) in partition_columns.iter().enumerate() {
1840 if primary_key_columns.get(i) != Some(partition_col) {
1841 return Err(format!(
1842 "Partition column '{}' is not a prefix of the primary key.",
1843 partition_col
1844 ));
1845 }
1846 }
1847
1848 Ok(())
1849}
1850
1851#[allow(clippy::too_many_arguments)]
1852pub async fn generate_stream_graph_for_replace_table(
1853 _session: &Arc<SessionImpl>,
1854 table_name: ObjectName,
1855 original_catalog: &Arc<TableCatalog>,
1856 handler_args: HandlerArgs,
1857 statement: Statement,
1858 col_id_gen: ColumnIdGenerator,
1859 sql_column_strategy: SqlColumnStrategy,
1860) -> Result<(
1861 StreamFragmentGraph,
1862 TableCatalog,
1863 Option<SourceCatalog>,
1864 TableJobType,
1865)> {
1866 let Statement::CreateTable {
1867 columns,
1868 constraints,
1869 source_watermarks,
1870 append_only,
1871 on_conflict,
1872 with_version_columns,
1873 wildcard_idx,
1874 cdc_table_info,
1875 format_encode,
1876 include_column_options,
1877 engine,
1878 with_options,
1879 ..
1880 } = statement
1881 else {
1882 panic!("unexpected statement type: {:?}", statement);
1883 };
1884
1885 let format_encode = format_encode
1886 .clone()
1887 .map(|format_encode| format_encode.into_v2_with_warning());
1888
1889 let engine = match engine {
1890 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1891 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1892 };
1893
1894 let is_drop_connector =
1895 original_catalog.associated_source_id().is_some() && format_encode.is_none();
1896 if is_drop_connector {
1897 debug_assert!(
1898 include_column_options.is_empty()
1899 && with_options
1900 .iter()
1901 .all(|opt| opt.name.real_value().to_lowercase() != "connector")
1902 );
1903 }
1904
1905 let props = CreateTableProps {
1906 definition: handler_args.normalized_sql.clone(),
1907 append_only,
1908 on_conflict: on_conflict.into(),
1909 with_version_columns: with_version_columns
1910 .iter()
1911 .map(|col| col.real_value())
1912 .collect(),
1913 webhook_info: original_catalog.webhook_info.clone(),
1914 engine,
1915 };
1916
1917 let ((plan, mut source, mut table), job_type) = match (format_encode, cdc_table_info.as_ref()) {
1918 (Some(format_encode), None) => (
1919 gen_create_table_plan_with_source(
1920 handler_args,
1921 ExplainOptions::default(),
1922 table_name,
1923 columns,
1924 wildcard_idx,
1925 constraints,
1926 format_encode,
1927 source_watermarks,
1928 col_id_gen,
1929 include_column_options,
1930 props,
1931 sql_column_strategy,
1932 )
1933 .await?,
1934 TableJobType::General,
1935 ),
1936 (None, None) => {
1937 let context = OptimizerContext::from_handler_args(handler_args);
1938 let (plan, table) = gen_create_table_plan(
1939 context,
1940 table_name,
1941 columns,
1942 constraints,
1943 col_id_gen,
1944 source_watermarks,
1945 props,
1946 true,
1947 )?;
1948 ((plan, None, table), TableJobType::General)
1949 }
1950 (None, Some(cdc_table)) => {
1951 sanity_check_for_table_on_cdc_source(
1952 append_only,
1953 &columns,
1954 &wildcard_idx,
1955 &constraints,
1956 &source_watermarks,
1957 )?;
1958
1959 let session = &handler_args.session;
1960 let (source, resolved_table_name) =
1961 get_source_and_resolved_table_name(session, cdc_table.clone(), table_name.clone())?;
1962
1963 let (cdc_with_options, normalized_external_table_name) =
1964 derive_with_options_for_cdc_table(
1965 &source.with_properties,
1966 cdc_table.external_table_name.clone(),
1967 )?;
1968
1969 let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
1970 let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1971 cdc_with_options.clone(),
1972 &pk_names,
1973 ))
1974 .await?;
1975
1976 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1979
1980 let context: OptimizerContextRef =
1981 OptimizerContext::new(handler_args, ExplainOptions::default()).into();
1982 let (plan, table) = gen_create_table_plan_for_cdc_table(
1983 context,
1984 source,
1985 normalized_external_table_name,
1986 columns,
1987 source_watermarks,
1988 column_catalogs,
1989 pk_names,
1990 pk_comparisons,
1991 cdc_with_options,
1992 col_id_gen,
1993 on_conflict,
1994 with_version_columns
1995 .iter()
1996 .map(|col| col.real_value())
1997 .collect(),
1998 include_column_options,
1999 table_name,
2000 resolved_table_name,
2001 original_catalog.database_id,
2002 original_catalog.schema_id,
2003 original_catalog.id(),
2004 engine,
2005 )?;
2006
2007 ((plan, None, table), TableJobType::SharedCdcSource)
2008 }
2009 (Some(_), Some(_)) => {
2010 return Err(ErrorCode::NotSupported(
2011 "Data format and encoding format doesn't apply to table created from a CDC source"
2012 .into(),
2013 "Remove the FORMAT and ENCODE specification".into(),
2014 )
2015 .into());
2016 }
2017 };
2018
2019 if table.pk_column_ids() != original_catalog.pk_column_ids() {
2020 Err(ErrorCode::InvalidInputSyntax(
2021 "alter primary key of table is not supported".to_owned(),
2022 ))?
2023 }
2024
2025 let graph = build_graph(plan, Some(GraphJobType::Table))?;
2026
2027 table.id = original_catalog.id();
2029 if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2030 table.associated_source_id = Some(source_id);
2031
2032 let source = source.as_mut().unwrap();
2033 source.id = source_id;
2034 source.associated_table_id = Some(table.id());
2035 }
2036
2037 Ok((graph, table, source, job_type))
2038}
2039
2040fn get_source_and_resolved_table_name(
2041 session: &Arc<SessionImpl>,
2042 cdc_table: CdcTableInfo,
2043 table_name: ObjectName,
2044) -> Result<(Arc<SourceCatalog>, String)> {
2045 let db_name = &session.database();
2046 let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2047
2048 let (source_schema, source_name) =
2049 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2050
2051 let source = {
2052 let catalog_reader = session.env().catalog_reader().read_guard();
2053 let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2054 let (source, _) = catalog_reader.get_source_by_name(
2055 db_name,
2056 SchemaPath::Name(schema_name.as_str()),
2057 source_name.as_str(),
2058 )?;
2059 source.clone()
2060 };
2061 check_cdc_source_select_privilege(session, &source)?;
2062
2063 Ok((source, resolved_table_name))
2064}
2065
2066pub(crate) fn check_cdc_source_select_privilege(
2067 session: &SessionImpl,
2068 source: &SourceCatalog,
2069) -> Result<()> {
2070 session.check_privileges(&[ObjectCheckItem::new(
2071 source.owner,
2072 AclMode::Select,
2073 source.name.clone(),
2074 source.id,
2075 )])
2076}
2077
2078fn bind_webhook_info(
2080 session: &Arc<SessionImpl>,
2081 column_defs: &[ColumnDef],
2082 webhook_info: WebhookSourceInfo,
2083) -> Result<PbWebhookSourceInfo> {
2084 let WebhookSourceInfo {
2085 secret_ref,
2086 signature_expr,
2087 wait_for_persistence,
2088 is_batched,
2089 } = webhook_info;
2090
2091 for column in column_defs {
2092 for option_def in &column.options {
2093 match option_def.option {
2094 ColumnOption::Null => {}
2095 ColumnOption::GeneratedColumns(_) => {
2096 return Err(ErrorCode::InvalidInputSyntax(
2097 "generated columns are not supported for webhook tables".to_owned(),
2098 )
2099 .into());
2100 }
2101 ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2102 return Err(ErrorCode::InvalidInputSyntax(
2103 "default values are not supported for webhook tables".to_owned(),
2104 )
2105 .into());
2106 }
2107 ColumnOption::NotNull
2108 | ColumnOption::Unique { .. }
2109 | ColumnOption::ForeignKey { .. }
2110 | ColumnOption::Check(_)
2111 | ColumnOption::DialectSpecific(_) => {
2112 return Err(ErrorCode::InvalidInputSyntax(
2113 "only NULL column option is supported for webhook tables".to_owned(),
2114 )
2115 .into());
2116 }
2117 }
2118 }
2119 }
2120
2121 let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2123 let db_name = &session.database();
2124 let (schema_name, secret_name) =
2125 Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2126 let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2127 (
2128 Some(PbSecretRef {
2129 secret_id: secret_catalog.id,
2130 ref_as: match secret_ref.ref_as {
2131 SecretRefAsType::Text => PbRefAsType::Text,
2132 SecretRefAsType::File => PbRefAsType::File,
2133 }
2134 .into(),
2135 }),
2136 Some(secret_name),
2137 )
2138 } else {
2139 (None, None)
2140 };
2141
2142 let signature_expr = if let Some(signature_expr) = signature_expr {
2143 let payload_name = if column_defs.len() == 1
2144 && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
2145 {
2146 column_defs[0].name.real_value()
2147 } else {
2148 WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
2149 };
2150 let secure_compare_context = SecureCompareContext {
2151 payload_name,
2152 secret_name,
2153 };
2154 let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
2155 let expr = binder.bind_expr(&signature_expr)?;
2156
2157 if expr.as_function_call().is_none()
2159 || expr.as_function_call().unwrap().func_type()
2160 != crate::optimizer::plan_node::generic::ExprType::SecureCompare
2161 {
2162 return Err(ErrorCode::InvalidInputSyntax(
2163 "The signature verification function must be SECURE_COMPARE()".to_owned(),
2164 )
2165 .into());
2166 }
2167
2168 Some(expr.to_expr_proto())
2169 } else {
2170 session.notice_to_user(
2171 "VALIDATE clause is strongly recommended for safety or production usages",
2172 );
2173 None
2174 };
2175
2176 let pb_webhook_info = PbWebhookSourceInfo {
2177 secret_ref: pb_secret_ref,
2178 signature_expr,
2179 wait_for_persistence,
2180 is_batched,
2181 };
2182
2183 Ok(pb_webhook_info)
2184}
2185
2186#[cfg(test)]
2187mod tests {
2188 use risingwave_common::catalog::{
2189 DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
2190 };
2191 use risingwave_common::types::StructType;
2192
2193 use super::*;
2194 use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
2195
2196 #[tokio::test]
2197 async fn test_iceberg_engine_primary_key_types() {
2198 let frontend = LocalFrontend::new(Default::default()).await;
2199 for (primary_key, append_only, pk_index, supported) in [
2200 ("id, period", false, false, false),
2201 ("id, items", false, false, false),
2202 ("id, mapping", false, false, false),
2203 ("id, f", false, false, false),
2204 ("id, d", false, false, false),
2205 ("id", false, false, true),
2206 ("id, ts", false, false, true),
2207 ("", false, false, true),
2208 ("id, period", true, false, true),
2209 ("id, period", false, true, true),
2210 ] {
2211 let pk_clause = if primary_key.is_empty() {
2212 String::new()
2213 } else {
2214 format!(", PRIMARY KEY ({primary_key})")
2215 };
2216 frontend
2217 .run_sql(&format!(
2218 "CREATE TABLE t (id INT, period STRUCT<start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ>, \
2219 items INT[], mapping MAP(INT, INT), f REAL, d DOUBLE PRECISION, ts TIMESTAMPTZ \
2220 {pk_clause}) {}",
2221 if append_only { "APPEND ONLY" } else { "" }
2222 ))
2223 .await
2224 .unwrap();
2225 let session = frontend.session_ref();
2226 let table = session
2227 .env()
2228 .catalog_reader()
2229 .read_guard()
2230 .get_created_table_by_name(
2231 DEFAULT_DATABASE_NAME,
2232 SchemaPath::Name(DEFAULT_SCHEMA_NAME),
2233 "t",
2234 )
2235 .unwrap()
2236 .0
2237 .clone();
2238 let pks = table
2239 .pk_column_names()
2240 .into_iter()
2241 .map(str::to_owned)
2242 .collect_vec();
2243 for write_mode in ["merge-on-read", "copy-on-write"] {
2244 if write_mode == "copy-on-write" && (append_only || pk_index) {
2245 continue;
2246 }
2247 let options = BTreeMap::from([
2248 ("connector".to_owned(), "iceberg".to_owned()),
2249 ("catalog.type".to_owned(), "storage".to_owned()),
2250 (
2251 "warehouse.path".to_owned(),
2252 "s3://test/warehouse".to_owned(),
2253 ),
2254 ("database.name".to_owned(), "public".to_owned()),
2255 ("table.name".to_owned(), "t".to_owned()),
2256 ("write_mode".to_owned(), write_mode.to_owned()),
2257 ("enable_pk_index".to_owned(), pk_index.to_string()),
2258 ]);
2259 let result = build_iceberg_engine_sink_options(
2260 options,
2261 &WithOptions::default(),
2262 &table,
2263 &pks,
2264 );
2265 if supported {
2266 result.unwrap();
2267 } else {
2268 let err = result.unwrap_err().to_report_string();
2269 let column = primary_key.split(", ").last().unwrap();
2270 assert!(
2271 err.contains(&format!(
2272 "Iceberg engine table primary key column \"{column}\" has unsupported type"
2273 )),
2274 "{write_mode}: {err}"
2275 );
2276 }
2277 }
2278 frontend.run_sql("DROP TABLE t").await.unwrap();
2279 }
2280 }
2281
2282 #[tokio::test]
2283 async fn test_cdc_relations_require_select_privilege_on_source() {
2284 let frontend = LocalFrontend::new(Default::default()).await;
2285 frontend
2288 .run_sql(
2289 r#"
2290 CREATE SOURCE cdc_source WITH (
2291 connector = 'postgres-cdc',
2292 hostname = 'localhost',
2293 port = '5432',
2294 username = 'postgres',
2295 password = '',
2296 database.name = 'db'
2297 ) FORMAT PLAIN ENCODE JSON
2298 "#,
2299 )
2300 .await
2301 .unwrap();
2302 frontend.run_sql("CREATE USER cdc_user").await.unwrap();
2303 frontend
2304 .run_sql("GRANT CREATE ON SCHEMA public TO cdc_user")
2305 .await
2306 .unwrap();
2307
2308 let user_id = frontend
2309 .session_ref()
2310 .env()
2311 .user_info_reader()
2312 .read_guard()
2313 .get_user_by_name("cdc_user")
2314 .unwrap()
2315 .id;
2316 let user_session = frontend.session_user_ref(
2317 DEFAULT_DATABASE_NAME.to_owned(),
2318 "cdc_user".to_owned(),
2319 user_id,
2320 );
2321 let create_table =
2322 "CREATE TABLE cdc_table (id INT PRIMARY KEY) FROM cdc_source TABLE 'public.t'";
2323 let create_table_source = "CREATE SOURCE cdc_table_source (id INT PRIMARY KEY) \
2324 WITH (snapshot = 'false') FROM cdc_source TABLE 'public.t'";
2325
2326 for sql in [create_table, create_table_source] {
2327 let err = frontend
2328 .run_sql_with_session(user_session.clone(), sql)
2329 .await
2330 .unwrap_err();
2331 assert!(
2332 err.to_string()
2333 .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2334 "{err:?}"
2335 );
2336 }
2337
2338 frontend
2339 .run_sql("GRANT SELECT ON SOURCE cdc_source TO cdc_user")
2340 .await
2341 .unwrap();
2342 frontend
2343 .run_sql_with_session(user_session.clone(), create_table)
2344 .await
2345 .unwrap();
2346 frontend
2347 .run_sql_with_session(user_session.clone(), create_table_source)
2348 .await
2349 .unwrap();
2350
2351 let table_source = {
2352 let catalog_reader = frontend.session_ref().env().catalog_reader().read_guard();
2353 catalog_reader
2354 .get_source_by_name(
2355 DEFAULT_DATABASE_NAME,
2356 SchemaPath::Name("public"),
2357 "cdc_table_source",
2358 )
2359 .unwrap()
2360 .0
2361 .clone()
2362 };
2363 assert_eq!(table_source.with_properties.len(), 2);
2364 assert_eq!(
2365 table_source.with_properties.get_connector().as_deref(),
2366 Some("postgres-cdc")
2367 );
2368 assert_eq!(
2369 table_source
2370 .with_properties
2371 .get("snapshot")
2372 .map(String::as_str),
2373 Some("false")
2374 );
2375 assert!(table_source.with_properties.as_secret().is_empty());
2376 let external_table = table_source.info.external_table.as_ref().unwrap();
2380 assert!(!external_table.connect_properties.is_empty());
2381
2382 let err = frontend
2383 .run_sql_with_session(
2384 user_session.clone(),
2385 "SELECT * FROM postgres_query('cdc_table_source', 'SELECT 1')",
2386 )
2387 .await
2388 .unwrap_err();
2389 assert!(
2390 err.to_report_string()
2391 .contains("only accept shared CDC sources, not CDC table sources"),
2392 "{err:?}"
2393 );
2394
2395 frontend
2396 .run_sql("REVOKE SELECT ON SOURCE cdc_source FROM cdc_user")
2397 .await
2398 .unwrap();
2399 let err = frontend
2400 .run_sql_with_session(user_session, "ALTER TABLE cdc_table ADD COLUMN value INT")
2401 .await
2402 .unwrap_err();
2403 assert!(
2404 err.to_string()
2405 .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2406 "{err:?}"
2407 );
2408 }
2409
2410 #[tokio::test]
2411 async fn test_create_table_handler() {
2412 let sql =
2413 "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
2414 let frontend = LocalFrontend::new(Default::default()).await;
2415 frontend.run_sql(sql).await.unwrap();
2416
2417 let session = frontend.session_ref();
2418 let catalog_reader = session.env().catalog_reader().read_guard();
2419 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2420
2421 let (table, _) = catalog_reader
2423 .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2424 .unwrap();
2425 assert_eq!(table.name(), "t");
2426
2427 let columns = table
2428 .columns
2429 .iter()
2430 .map(|col| (col.name(), col.data_type().clone()))
2431 .collect::<HashMap<&str, DataType>>();
2432
2433 let expected_columns = maplit::hashmap! {
2434 ROW_ID_COLUMN_NAME => DataType::Serial,
2435 "v1" => DataType::Int16,
2436 "v2" => StructType::new(
2437 vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
2438 )
2439 .with_ids([3, 4, 5].map(ColumnId::new))
2440 .into(),
2441 RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
2442 };
2443
2444 assert_eq!(columns, expected_columns, "{columns:#?}");
2445 }
2446
2447 #[tokio::test]
2448 async fn test_create_webhook_table_with_arbitrary_columns() {
2449 let frontend = LocalFrontend::new(Default::default()).await;
2450 frontend
2451 .run_sql("create schema ingest_schema;")
2452 .await
2453 .unwrap();
2454 frontend
2455 .run_sql(
2456 r#"
2457 create table ingest_schema.orders (
2458 id int,
2459 customer_name varchar,
2460 amount double precision,
2461 primary key (id)
2462 ) with (
2463 connector = 'webhook'
2464 ) validate as secure_compare(
2465 headers->>'x-rw-signature',
2466 'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
2467 );
2468 "#,
2469 )
2470 .await
2471 .unwrap();
2472
2473 let session = frontend.session_ref();
2474 let catalog_reader = session.env().catalog_reader().read_guard();
2475 let (table, _) = catalog_reader
2476 .get_created_table_by_name(
2477 DEFAULT_DATABASE_NAME,
2478 SchemaPath::Name("ingest_schema"),
2479 "orders",
2480 )
2481 .unwrap();
2482
2483 assert!(table.webhook_info.is_some());
2484 assert_eq!(
2485 table
2486 .columns
2487 .iter()
2488 .filter(|column| column.can_dml())
2489 .count(),
2490 3
2491 );
2492 }
2493
2494 #[tokio::test]
2495 async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
2496 let frontend = LocalFrontend::new(Default::default()).await;
2497 frontend
2498 .run_sql(
2499 r#"
2500 create table webhook_single_column (
2501 body jsonb
2502 ) with (
2503 connector = 'webhook'
2504 ) validate as secure_compare(
2505 headers->>'x-rw-signature',
2506 'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
2507 );
2508 "#,
2509 )
2510 .await
2511 .unwrap();
2512 }
2513
2514 #[tokio::test]
2515 async fn test_create_webhook_table_with_generated_columns() {
2516 let frontend = LocalFrontend::new(Default::default()).await;
2517 let err = frontend
2518 .run_sql(
2519 r#"
2520 create table webhook_generated_columns (
2521 id int,
2522 amount double precision,
2523 amount_with_fee double precision as amount + 1.0
2524 ) with (
2525 connector = 'webhook'
2526 );
2527 "#,
2528 )
2529 .await
2530 .unwrap_err();
2531
2532 assert!(
2533 err.to_string()
2534 .contains("generated columns are not supported for webhook tables"),
2535 "{err:?}"
2536 );
2537 }
2538
2539 #[tokio::test]
2540 async fn test_create_webhook_table_with_default_value() {
2541 let frontend = LocalFrontend::new(Default::default()).await;
2542 let err = frontend
2543 .run_sql(
2544 r#"
2545 create table webhook_default_value (
2546 id int default 42,
2547 amount double precision
2548 ) with (
2549 connector = 'webhook'
2550 );
2551 "#,
2552 )
2553 .await
2554 .unwrap_err();
2555
2556 assert!(
2557 err.to_string()
2558 .contains("default values are not supported for webhook tables"),
2559 "{err:?}"
2560 );
2561 }
2562
2563 #[tokio::test]
2564 async fn test_create_webhook_table_with_not_null_option() {
2565 let frontend = LocalFrontend::new(Default::default()).await;
2566 let err = frontend
2567 .run_sql(
2568 r#"
2569 create table webhook_not_null (
2570 id int not null,
2571 amount double precision
2572 ) with (
2573 connector = 'webhook'
2574 );
2575 "#,
2576 )
2577 .await
2578 .unwrap_err();
2579
2580 assert!(
2581 err.to_string()
2582 .contains("only NULL column option is supported for webhook tables"),
2583 "{err:?}"
2584 );
2585 }
2586
2587 #[test]
2588 fn test_bind_primary_key() {
2589 for (sql, expected) in [
2592 ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
2593 ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
2594 ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
2595 (
2596 "create table t (v1 int primary key, v2 int primary key)",
2597 Err("multiple primary keys are not allowed"),
2598 ),
2599 (
2600 "create table t (v1 int primary key primary key, v2 int)",
2601 Err("multiple primary keys are not allowed"),
2602 ),
2603 (
2604 "create table t (v1 int, v2 int, primary key (v1))",
2605 Ok(&[1]),
2606 ),
2607 (
2608 "create table t (v1 int, primary key (v2), v2 int)",
2609 Ok(&[2]),
2610 ),
2611 (
2612 "create table t (primary key (v2, v1), v1 int, v2 int)",
2613 Ok(&[2, 1]),
2614 ),
2615 (
2616 "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
2617 Err("multiple primary keys are not allowed"),
2618 ),
2619 (
2620 "create table t (v1 int primary key, primary key (v1), v2 int)",
2621 Err("multiple primary keys are not allowed"),
2622 ),
2623 (
2624 "create table t (v1 int, primary key (V3), v2 int)",
2625 Err("column \"v3\" named in key does not exist"),
2626 ),
2627 ] {
2628 let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
2629 let risingwave_sqlparser::ast::Statement::CreateTable {
2630 columns: column_defs,
2631 constraints,
2632 ..
2633 } = ast.remove(0)
2634 else {
2635 panic!("test case should be create table")
2636 };
2637 let actual: Result<_> = (|| {
2638 let mut columns = bind_sql_columns(&column_defs, false)?;
2639 let mut col_id_gen = ColumnIdGenerator::new_initial();
2640 for c in &mut columns {
2641 col_id_gen.generate(c)?;
2642 }
2643
2644 let pk_names =
2645 bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
2646 let (_, pk_column_ids, _) =
2647 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
2648 Ok(pk_column_ids)
2649 })();
2650 match (expected, actual) {
2651 (Ok(expected), Ok(actual)) => assert_eq!(
2652 expected.iter().copied().map(ColumnId::new).collect_vec(),
2653 actual,
2654 "sql: {sql}"
2655 ),
2656 (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
2657 (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
2658 (Err(expected), Err(actual)) => assert!(
2659 actual.to_string().contains(expected),
2660 "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
2661 ),
2662 }
2663 }
2664 }
2665
2666 #[tokio::test]
2667 async fn test_duplicate_props_options() {
2668 let proto_file = create_proto_file(PROTO_FILE_DATA);
2669 let sql = format!(
2670 r#"CREATE TABLE t
2671 WITH (
2672 connector = 'kinesis',
2673 aws.region='user_test_topic',
2674 endpoint='172.10.1.1:9090,172.10.1.2:9090',
2675 aws.credentials.access_key_id = 'your_access_key_1',
2676 aws.credentials.secret_access_key = 'your_secret_key_1'
2677 )
2678 FORMAT PLAIN ENCODE PROTOBUF (
2679 message = '.test.TestRecord',
2680 aws.credentials.access_key_id = 'your_access_key_2',
2681 aws.credentials.secret_access_key = 'your_secret_key_2',
2682 schema.location = 'file://{}',
2683 )"#,
2684 proto_file.path().to_str().unwrap()
2685 );
2686 let frontend = LocalFrontend::new(Default::default()).await;
2687 frontend.run_sql(sql).await.unwrap();
2688
2689 let session = frontend.session_ref();
2690 let catalog_reader = session.env().catalog_reader().read_guard();
2691 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2692
2693 let (source, _) = catalog_reader
2695 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2696 .unwrap();
2697 assert_eq!(source.name, "t");
2698
2699 assert_eq!(
2701 source
2702 .info
2703 .format_encode_options
2704 .get("aws.credentials.access_key_id")
2705 .unwrap(),
2706 "your_access_key_2"
2707 );
2708 assert_eq!(
2709 source
2710 .info
2711 .format_encode_options
2712 .get("aws.credentials.secret_access_key")
2713 .unwrap(),
2714 "your_secret_key_2"
2715 );
2716
2717 assert_eq!(
2719 source
2720 .with_properties
2721 .get("aws.credentials.access_key_id")
2722 .unwrap(),
2723 "your_access_key_1"
2724 );
2725 assert_eq!(
2726 source
2727 .with_properties
2728 .get("aws.credentials.secret_access_key")
2729 .unwrap(),
2730 "your_secret_key_1"
2731 );
2732
2733 assert!(!source.with_properties.contains_key("schema.location"));
2735 }
2736}