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(
930 context.with_options(),
931 cdc_table_desc.connect_properties.cdc_source_type(),
932 )?;
933
934 let logical_scan = LogicalCdcScan::create(
935 external_table_name.clone(),
936 Rc::new(cdc_table_desc),
937 context.clone(),
938 options,
939 );
940
941 let scan_node: LogicalPlanRef = logical_scan.into();
942 let required_cols = FixedBitSet::with_capacity(non_generated_column_num);
943 let plan_root = PlanRoot::new_with_logical_plan(
944 scan_node,
945 RequiredDist::Any,
946 Order::any(),
947 required_cols,
948 vec![],
949 );
950
951 let cdc_table_id_external_table_name = if let ExternalCdcTableType::Postgres = cdc_table_type
952 && let Some(normalized_table_name) =
953 normalize_simple_postgres_quoted_table_name(&external_table_name)
954 {
955 normalized_table_name
956 } else {
957 external_table_name
958 };
959 let cdc_table_id = build_cdc_table_id(source.id, &cdc_table_id_external_table_name);
960 let materialize = plan_root.gen_table_plan(
961 context,
962 resolved_table_name,
963 database_id,
964 schema_id,
965 CreateTableInfo {
966 columns,
967 pk_column_ids,
968 row_id_index: None,
969 watermark_descs,
970 source_catalog: Some((*source).clone()),
971 version: col_id_gen.into_version(),
972 },
973 CreateTableProps {
974 definition,
975 append_only: false,
976 on_conflict: on_conflict.into(),
977 with_version_columns,
978 webhook_info: None,
979 engine,
980 },
981 )?;
982
983 let mut table = materialize.table().clone();
984 table.owner = session.user_id();
985 table.cdc_table_id = Some(cdc_table_id);
986 table.cdc_table_type = Some(cdc_table_type);
987 Ok((
988 ensure_sync_log_store_fragment_root(materialize.into()),
989 table,
990 ))
991}
992
993#[allow(clippy::too_many_arguments)]
994pub(super) async fn handle_create_table_plan(
995 handler_args: HandlerArgs,
996 explain_options: ExplainOptions,
997 format_encode: Option<FormatEncodeOptions>,
998 cdc_table_info: Option<CdcTableInfo>,
999 table_name: &ObjectName,
1000 column_defs: Vec<ColumnDef>,
1001 wildcard_idx: Option<usize>,
1002 constraints: Vec<TableConstraint>,
1003 source_watermarks: Vec<SourceWatermark>,
1004 append_only: bool,
1005 on_conflict: Option<OnConflict>,
1006 with_version_columns: Vec<String>,
1007 include_column_options: IncludeOption,
1008 webhook_info: Option<WebhookSourceInfo>,
1009 engine: Engine,
1010) -> Result<(
1011 PlanRef,
1012 Option<SourceCatalog>,
1013 TableCatalog,
1014 TableJobType,
1015 Option<SourceId>,
1016)> {
1017 let col_id_gen = ColumnIdGenerator::new_initial();
1018 let format_encode = check_create_table_with_source(
1019 &handler_args.with_options,
1020 format_encode,
1021 &include_column_options,
1022 &cdc_table_info,
1023 )?;
1024 let webhook_info = webhook_info
1025 .map(|info| bind_webhook_info(&handler_args.session, &column_defs, info))
1026 .transpose()?;
1027
1028 let props = CreateTableProps {
1029 definition: handler_args.normalized_sql.clone(),
1030 append_only,
1031 on_conflict: on_conflict.into(),
1032 with_version_columns: with_version_columns.clone(),
1033 webhook_info,
1034 engine,
1035 };
1036
1037 let ((plan, source, table), job_type, shared_shource_id) = match (
1038 format_encode,
1039 cdc_table_info.as_ref(),
1040 ) {
1041 (Some(format_encode), None) => (
1042 gen_create_table_plan_with_source(
1043 handler_args,
1044 explain_options,
1045 table_name.clone(),
1046 column_defs,
1047 wildcard_idx,
1048 constraints,
1049 format_encode,
1050 source_watermarks,
1051 col_id_gen,
1052 include_column_options,
1053 props,
1054 SqlColumnStrategy::FollowChecked,
1055 )
1056 .await?,
1057 TableJobType::General,
1058 None,
1059 ),
1060 (None, None) => {
1061 let context = OptimizerContext::new(handler_args, explain_options);
1062 let (plan, table) = gen_create_table_plan(
1063 context,
1064 table_name.clone(),
1065 column_defs,
1066 constraints,
1067 col_id_gen,
1068 source_watermarks,
1069 props,
1070 false,
1071 )?;
1072
1073 ((plan, None, table), TableJobType::General, None)
1074 }
1075
1076 (None, Some(cdc_table)) => {
1077 sanity_check_for_table_on_cdc_source(
1078 append_only,
1079 &column_defs,
1080 &wildcard_idx,
1081 &constraints,
1082 &source_watermarks,
1083 )?;
1084
1085 generated_columns_check_for_cdc_table(&column_defs)?;
1086 not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
1087
1088 let session = &handler_args.session;
1089 let db_name = &session.database();
1090 let user_name = &session.user_name();
1091 let search_path = session.config().search_path();
1092 let (schema_name, resolved_table_name) =
1093 Binder::resolve_schema_qualified_name(db_name, table_name)?;
1094 let (database_id, schema_id) =
1095 session.get_database_and_schema_id_for_create(schema_name.clone())?;
1096
1097 let (source_schema, source_name) =
1099 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
1100
1101 let source = {
1102 let catalog_reader = session.env().catalog_reader().read_guard();
1103 let schema_path =
1104 SchemaPath::new(source_schema.as_deref(), &search_path, user_name);
1105
1106 let (source, _) = catalog_reader.get_source_by_name(
1107 db_name,
1108 schema_path,
1109 source_name.as_str(),
1110 )?;
1111 source.clone()
1112 };
1113 check_cdc_source_select_privilege(session, &source)?;
1114 let (cdc_with_options, normalized_external_table_name) =
1115 derive_with_options_for_cdc_table(
1116 &source.with_properties,
1117 cdc_table.external_table_name.clone(),
1118 )?;
1119
1120 let (columns, pk_names, pk_comparisons) = match wildcard_idx {
1121 Some(_) => bind_cdc_table_schema_externally(cdc_with_options.clone()).await?,
1122 None => {
1123 for column_def in &column_defs {
1124 for option_def in &column_def.options {
1125 if let ColumnOption::DefaultValue(_)
1126 | ColumnOption::DefaultValueInternal { .. } = option_def.option
1127 {
1128 return Err(ErrorCode::NotSupported(
1129 "Default value for columns defined on the table created from a CDC source".into(),
1130 "Remove the default value expression in the column definitions".into(),
1131 )
1132 .into());
1133 }
1134 }
1135 }
1136
1137 let (columns, pk_names) =
1138 bind_cdc_table_schema(&column_defs, &constraints, false)?;
1139 let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1140 cdc_with_options.clone(),
1141 &pk_names,
1142 ))
1143 .await?;
1144
1145 (columns, pk_names, pk_comparisons)
1146 }
1147 };
1148
1149 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1154
1155 let context: OptimizerContextRef =
1156 OptimizerContext::new(handler_args, explain_options).into();
1157 let shared_source_id = source.id;
1158 let (plan, table) = gen_create_table_plan_for_cdc_table(
1159 context,
1160 source,
1161 normalized_external_table_name,
1162 column_defs,
1163 source_watermarks,
1164 columns,
1165 pk_names,
1166 pk_comparisons,
1167 cdc_with_options,
1168 col_id_gen,
1169 on_conflict,
1170 with_version_columns,
1171 include_column_options,
1172 table_name.clone(),
1173 resolved_table_name,
1174 database_id,
1175 schema_id,
1176 TableId::placeholder(),
1177 engine,
1178 )?;
1179
1180 (
1181 (plan, None, table),
1182 TableJobType::SharedCdcSource,
1183 Some(shared_source_id),
1184 )
1185 }
1186 (Some(_), Some(_)) => {
1187 return Err(ErrorCode::NotSupported(
1188 "Data format and encoding format doesn't apply to table created from a CDC source"
1189 .into(),
1190 "Remove the FORMAT and ENCODE specification".into(),
1191 )
1192 .into());
1193 }
1194 };
1195 Ok((plan, source, table, job_type, shared_shource_id))
1196}
1197
1198fn generated_columns_check_for_cdc_table(columns: &Vec<ColumnDef>) -> Result<()> {
1200 let mut found_generated_column = false;
1201 for column in columns {
1202 let mut is_generated = false;
1203
1204 for option_def in &column.options {
1205 if let ColumnOption::GeneratedColumns(_) = option_def.option {
1206 is_generated = true;
1207 break;
1208 }
1209 }
1210
1211 if is_generated {
1212 found_generated_column = true;
1213 } else if found_generated_column {
1214 return Err(ErrorCode::NotSupported(
1215 "Non-generated column found after a generated column.".into(),
1216 "Ensure that all generated columns appear at the end of the cdc table definition."
1217 .into(),
1218 )
1219 .into());
1220 }
1221 }
1222 Ok(())
1223}
1224
1225#[allow(clippy::too_many_arguments)]
1226pub async fn handle_create_table(
1227 handler_args: HandlerArgs,
1228 table_name: ObjectName,
1229 column_defs: Vec<ColumnDef>,
1230 wildcard_idx: Option<usize>,
1231 constraints: Vec<TableConstraint>,
1232 if_not_exists: bool,
1233 format_encode: Option<FormatEncodeOptions>,
1234 source_watermarks: Vec<SourceWatermark>,
1235 append_only: bool,
1236 on_conflict: Option<OnConflict>,
1237 with_version_columns: Vec<String>,
1238 cdc_table_info: Option<CdcTableInfo>,
1239 include_column_options: IncludeOption,
1240 webhook_info: Option<WebhookSourceInfo>,
1241 ast_engine: risingwave_sqlparser::ast::Engine,
1242) -> Result<RwPgResponse> {
1243 let session = handler_args.session.clone();
1244
1245 if append_only {
1246 session.notice_to_user("APPEND ONLY TABLE is currently an experimental feature.");
1247 }
1248
1249 session.check_cluster_limits().await?;
1250
1251 let engine = match ast_engine {
1252 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1253 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1254 };
1255
1256 if let Either::Right(resp) = session.check_relation_name_duplicated(
1257 table_name.clone(),
1258 StatementType::CREATE_TABLE,
1259 if_not_exists,
1260 )? {
1261 return Ok(resp);
1262 }
1263
1264 let (graph, source, hummock_table, job_type, shared_source_id) = {
1265 let (plan, source, table, job_type, shared_source_id) = Box::pin(handle_create_table_plan(
1266 handler_args.clone(),
1267 ExplainOptions::default(),
1268 format_encode,
1269 cdc_table_info,
1270 &table_name,
1271 column_defs.clone(),
1272 wildcard_idx,
1273 constraints.clone(),
1274 source_watermarks,
1275 append_only,
1276 on_conflict,
1277 with_version_columns,
1278 include_column_options,
1279 webhook_info,
1280 engine,
1281 ))
1282 .await?;
1283 tracing::trace!("table_plan: {:?}", plan.explain_to_string());
1284
1285 let graph = build_graph(plan, Some(GraphJobType::Table))?;
1286
1287 (graph, source, table, job_type, shared_source_id)
1288 };
1289
1290 tracing::trace!(
1291 "name={}, graph=\n{}",
1292 table_name,
1293 serde_json::to_string_pretty(&graph).unwrap()
1294 );
1295
1296 let dependencies = shared_source_id
1297 .map(|id| HashSet::from([id.as_object_id()]))
1298 .unwrap_or_default();
1299
1300 match engine {
1302 Engine::Hummock => {
1303 let catalog_writer = session.catalog_writer()?;
1304 let action = match job_type {
1305 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1306 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1307 };
1308 execute_with_long_running_notification(
1309 catalog_writer.create_table(
1310 source.map(|s| s.to_prost()),
1311 hummock_table.to_prost(),
1312 graph,
1313 job_type,
1314 if_not_exists,
1315 dependencies,
1316 ),
1317 &session,
1318 "CREATE TABLE",
1319 action,
1320 )
1321 .await?;
1322 }
1323 Engine::Iceberg => {
1324 let hummock_table_name = hummock_table.name.clone();
1325 session.create_staging_table(hummock_table.clone());
1326 let res = Box::pin(create_iceberg_engine_table(
1327 session.clone(),
1328 handler_args,
1329 source.map(|s| s.to_prost()),
1330 hummock_table,
1331 graph,
1332 table_name,
1333 job_type,
1334 if_not_exists,
1335 ))
1336 .await;
1337 session.drop_staging_table(&hummock_table_name);
1338 res?
1339 }
1340 }
1341
1342 Ok(PgResponse::empty_result(StatementType::CREATE_TABLE))
1343}
1344
1345fn build_iceberg_engine_sink_options(
1346 mut sink_options: BTreeMap<String, String>,
1347 user_options: &WithOptions,
1348 table: &TableCatalog,
1349 primary_key: &[String],
1350) -> Result<BTreeMap<String, String>> {
1351 sink_options.extend(
1352 user_options
1353 .iter()
1354 .filter(|(key, _)| is_iceberg_engine_option(key))
1355 .map(|(key, value)| (key.clone(), value.clone())),
1356 );
1357
1358 sink_options
1359 .entry(ENABLE_COMPACTION.to_owned())
1360 .or_insert_with(|| "true".to_owned());
1361 sink_options.insert(
1362 "type".to_owned(),
1363 if table.append_only {
1364 "append-only"
1365 } else {
1366 "upsert"
1367 }
1368 .to_owned(),
1369 );
1370
1371 if !table.append_only {
1374 sink_options.insert("primary_key".to_owned(), primary_key.join(","));
1375 }
1376
1377 sink_options.insert("create_table_if_not_exists".to_owned(), "true".to_owned());
1378 sink_options.insert("is_exactly_once".to_owned(), "true".to_owned());
1379
1380 let config = IcebergConfig::from_btreemap(sink_options.clone())?;
1381
1382 if !table.append_only && !config.enable_pk_index {
1385 for pk in table.pk() {
1386 let column = &table.columns()[pk.column_index];
1387 let data_type = column.data_type();
1388 if data_type.is_composite()
1389 || matches!(data_type, DataType::Float32 | DataType::Float64)
1390 {
1391 return Err(ErrorCode::NotSupported(
1392 format!(
1393 "Iceberg engine table primary key column \"{}\" has unsupported type {} for equality deletes",
1394 column.name(), data_type
1395 ),
1396 "Use non-floating-point scalar primary key columns.".to_owned(),
1397 )
1398 .into());
1399 }
1400 }
1401 }
1402
1403 if config.table_format_version() < FormatVersion::V3 {
1406 sink_options
1407 .entry(ENABLE_MANIFEST_REWRITE.to_owned())
1408 .or_insert_with(|| "true".to_owned());
1409 }
1410
1411 if let Some(partition_by) = &config.partition_by {
1412 let mut partition_columns = vec![];
1413 for (column, _) in parse_partition_by_exprs(partition_by.clone())? {
1414 table
1415 .columns()
1416 .iter()
1417 .find(|col| col.name().eq_ignore_ascii_case(&column))
1418 .ok_or_else(|| {
1419 ErrorCode::InvalidInputSyntax(format!(
1420 "Partition source column does not exist in schema: {}",
1421 column
1422 ))
1423 })?;
1424
1425 partition_columns.push(column);
1426 }
1427
1428 ensure_partition_columns_are_prefix_of_primary_key(&partition_columns, primary_key)
1429 .map_err(|_| {
1430 ErrorCode::InvalidInputSyntax(
1431 "The partition columns should be the prefix of the primary key".to_owned(),
1432 )
1433 })?;
1434 }
1435
1436 if let Some(order_key) = &config.order_key {
1437 validate_order_key_columns(order_key, table.columns().iter().map(|col| col.name()))
1438 .map_err(|err| ErrorCode::InvalidInputSyntax(err.to_report_string()))?;
1439 }
1440
1441 if config.enable_pk_index {
1442 sink_options.remove("primary_key");
1443 } else {
1444 sink_options.insert(AUTO_SCHEMA_CHANGE_KEY.to_owned(), "true".to_owned());
1445 }
1446
1447 Ok(sink_options)
1448}
1449
1450#[allow(clippy::too_many_arguments)]
1459pub async fn create_iceberg_engine_table(
1460 session: Arc<SessionImpl>,
1461 handler_args: HandlerArgs,
1462 mut source: Option<PbSource>,
1463 table: TableCatalog,
1464 graph: StreamFragmentGraph,
1465 table_name: ObjectName,
1466 job_type: PbTableJobType,
1467 if_not_exists: bool,
1468) -> Result<()> {
1469 let rw_db_name = session
1470 .env()
1471 .catalog_reader()
1472 .read_guard()
1473 .get_database_by_id(table.database_id)?
1474 .name()
1475 .to_owned();
1476 let rw_schema_name = session
1477 .env()
1478 .catalog_reader()
1479 .read_guard()
1480 .get_schema_by_id(table.database_id, table.schema_id)?
1481 .name()
1482 .clone();
1483 let iceberg_catalog_name = rw_db_name.clone();
1484 let iceberg_database_name = rw_schema_name.clone();
1485 let iceberg_table_name = table_name.0.last().unwrap().real_value();
1486
1487 let iceberg_engine_connection: String = session.config().iceberg_engine_connection();
1488 let sink_decouple = session.config().sink_decouple();
1489 if matches!(sink_decouple, SinkDecouple::Disable) {
1490 bail!(
1491 "Iceberg engine table only supports with sink decouple, try `set sink_decouple = true` to resolve it"
1492 );
1493 }
1494
1495 let mut connection_ref = BTreeMap::new();
1496 let with_common = if iceberg_engine_connection.is_empty() {
1497 bail!("to use iceberg engine table, the variable `iceberg_engine_connection` must be set.");
1498 } else {
1499 let parts: Vec<&str> = iceberg_engine_connection.split('.').collect();
1500 assert_eq!(parts.len(), 2);
1501 let connection_catalog =
1502 session.get_connection_by_name(Some(parts[0].to_owned()), parts[1])?;
1503 if let ConnectionInfo::ConnectionParams(params) = &connection_catalog.info {
1504 if params.connection_type == ConnectionType::Iceberg as i32 {
1505 connection_ref.insert(
1507 "connection".to_owned(),
1508 ConnectionRefValue {
1509 connection_name: ObjectName::from(vec![
1510 Ident::from(parts[0]),
1511 Ident::from(parts[1]),
1512 ]),
1513 },
1514 );
1515
1516 let mut with_common = BTreeMap::new();
1517 with_common.insert("connector".to_owned(), "iceberg".to_owned());
1518 with_common.insert("database.name".to_owned(), iceberg_database_name);
1519 with_common.insert("table.name".to_owned(), iceberg_table_name);
1520
1521 let hosted_catalog = params
1522 .properties
1523 .get("hosted_catalog")
1524 .map(|s| s.eq_ignore_ascii_case("true"))
1525 .unwrap_or(false);
1526 if hosted_catalog {
1527 let meta_client = session.env().meta_client();
1528 let meta_store_endpoint = meta_client.get_meta_store_endpoint().await?;
1529
1530 let meta_store_endpoint =
1531 url::Url::parse(&meta_store_endpoint).map_err(|_| {
1532 ErrorCode::InternalError(
1533 "failed to parse the meta store endpoint".to_owned(),
1534 )
1535 })?;
1536 let meta_store_backend = meta_store_endpoint.scheme().to_owned();
1537 let meta_store_user = meta_store_endpoint.username().to_owned();
1538 let meta_store_password = match meta_store_endpoint.password() {
1539 Some(password) => percent_decode_str(password)
1540 .decode_utf8()
1541 .map_err(|_| {
1542 ErrorCode::InternalError(
1543 "failed to parse password from meta store endpoint".to_owned(),
1544 )
1545 })?
1546 .into_owned(),
1547 None => "".to_owned(),
1548 };
1549 let meta_store_host = meta_store_endpoint
1550 .host_str()
1551 .ok_or_else(|| {
1552 ErrorCode::InternalError(
1553 "failed to parse host from meta store endpoint".to_owned(),
1554 )
1555 })?
1556 .to_owned();
1557 let meta_store_port = meta_store_endpoint.port().ok_or_else(|| {
1558 ErrorCode::InternalError(
1559 "failed to parse port from meta store endpoint".to_owned(),
1560 )
1561 })?;
1562 let meta_store_database = meta_store_endpoint
1563 .path()
1564 .trim_start_matches('/')
1565 .to_owned();
1566
1567 let Ok(meta_backend) = MetaBackend::from_str(&meta_store_backend, true) else {
1568 bail!("failed to parse meta backend: {}", meta_store_backend);
1569 };
1570
1571 let catalog_uri = match meta_backend {
1572 MetaBackend::Postgres => {
1573 format!(
1574 "jdbc:postgresql://{}:{}/{}",
1575 meta_store_host, meta_store_port, meta_store_database
1576 )
1577 }
1578 MetaBackend::Mysql => {
1579 format!(
1580 "jdbc:mysql://{}:{}/{}",
1581 meta_store_host, meta_store_port, meta_store_database
1582 )
1583 }
1584 MetaBackend::Sqlite | MetaBackend::Sql | MetaBackend::Mem => {
1585 bail!(
1586 "Unsupported meta backend for iceberg engine table: {}",
1587 meta_store_backend
1588 );
1589 }
1590 };
1591
1592 with_common.insert("catalog.type".to_owned(), "jdbc".to_owned());
1593 with_common.insert("catalog.uri".to_owned(), catalog_uri);
1594 with_common.insert("catalog.jdbc.user".to_owned(), meta_store_user);
1595 with_common.insert("catalog.jdbc.password".to_owned(), meta_store_password);
1596 with_common.insert("catalog.name".to_owned(), iceberg_catalog_name);
1597 }
1598
1599 with_common
1600 } else {
1601 return Err(RwError::from(ErrorCode::InvalidParameterValue(
1602 "Only iceberg connection could be used in iceberg engine".to_owned(),
1603 )));
1604 }
1605 } else {
1606 return Err(RwError::from(ErrorCode::InvalidParameterValue(
1607 "Private Link Service has been deprecated. Please create a new connection instead."
1608 .to_owned(),
1609 )));
1610 }
1611 };
1612
1613 let mut pks = table
1616 .pk_column_names()
1617 .iter()
1618 .map(|c| c.to_string())
1619 .collect::<Vec<String>>();
1620
1621 if pks.len() == 1 && pks[0].eq(ROW_ID_COLUMN_NAME) {
1623 pks = vec![RISINGWAVE_ICEBERG_ROW_ID.to_owned()];
1624 }
1625
1626 let sink_from = CreateSink::From(table_name.clone());
1627
1628 let mut sink_name = table_name.clone();
1629 *sink_name.0.last_mut().unwrap() = Ident::from(
1630 (ICEBERG_SINK_PREFIX.to_owned() + &sink_name.0.last().unwrap().real_value()).as_str(),
1631 );
1632 let create_sink_stmt = CreateSinkStatement {
1633 or_replace: false,
1634 if_not_exists: false,
1635 sink_name,
1636 with_properties: WithProperties(vec![]),
1637 sink_from,
1638 columns: vec![],
1639 emit_mode: None,
1640 sink_schema: None,
1641 into_table_name: None,
1642 };
1643
1644 let mut sink_handler_args = handler_args.clone();
1645
1646 let sink_with = build_iceberg_engine_sink_options(
1647 with_common.clone(),
1648 &handler_args.with_options,
1649 &table,
1650 &pks,
1651 )?;
1652
1653 if let Some(source) = source.as_mut() {
1654 source
1655 .with_properties
1656 .retain(|key, _| !is_iceberg_engine_option(key));
1657 }
1658
1659 sink_handler_args.with_options =
1679 WithOptions::new(sink_with, Default::default(), connection_ref.clone());
1680 let SinkPlanContext {
1681 sink_plan,
1682 sink_catalog,
1683 ..
1684 } = gen_sink_plan(sink_handler_args, create_sink_stmt, None, true).await?;
1685 let sink_graph = build_graph(sink_plan, Some(GraphJobType::Sink))?;
1686
1687 let mut source_name = table_name.clone();
1688 *source_name.0.last_mut().unwrap() = Ident::from(
1689 (ICEBERG_SOURCE_PREFIX.to_owned() + &source_name.0.last().unwrap().real_value()).as_str(),
1690 );
1691 let create_source_stmt = CreateSourceStatement {
1692 temporary: false,
1693 if_not_exists: false,
1694 columns: vec![],
1695 source_name,
1696 wildcard_idx: Some(0),
1697 constraints: vec![],
1698 with_properties: WithProperties(vec![]),
1699 format_encode: CompatibleFormatEncode::V2(FormatEncodeOptions::none()),
1700 source_watermarks: vec![],
1701 include_column_options: vec![],
1702 cdc_table_info: None,
1703 };
1704
1705 let mut source_handler_args = handler_args.clone();
1706 let source_with = with_common;
1707 source_handler_args.with_options =
1708 WithOptions::new(source_with, Default::default(), connection_ref);
1709
1710 let overwrite_options = OverwriteOptions::new(&mut source_handler_args);
1711 let format_encode = create_source_stmt.format_encode.into_v2_with_warning();
1712 let (with_properties, refresh_mode) =
1713 bind_connector_props(&source_handler_args, &format_encode, true)?;
1714
1715 let (iceberg_catalog, table_identifier, table_created) = {
1718 let sink_param = SinkParam::try_from_sink_catalog(sink_catalog.clone())?;
1719 let iceberg_sink = IcebergSink::try_from(sink_param)?;
1720 let table_created = iceberg_sink.create_table_if_not_exists().await?;
1721
1722 let iceberg_catalog = iceberg_sink.config.create_catalog().await?;
1723 let table_identifier = iceberg_sink.config.full_table_name()?;
1724 (iceberg_catalog, table_identifier, table_created)
1725 };
1726
1727 let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
1728 let (columns_from_resolve_source, source_info) = bind_columns_from_source(
1729 &session,
1730 &format_encode,
1731 Either::Left(&with_properties),
1732 create_source_type,
1733 )
1734 .await?;
1735 let mut col_id_gen = ColumnIdGenerator::new_initial();
1736
1737 let iceberg_source_catalog = bind_create_source_or_table_with_connector(
1738 source_handler_args,
1739 create_source_stmt.source_name,
1740 format_encode,
1741 with_properties,
1742 &create_source_stmt.columns,
1743 create_source_stmt.constraints,
1744 create_source_stmt.wildcard_idx,
1745 create_source_stmt.source_watermarks,
1746 columns_from_resolve_source,
1747 source_info,
1748 create_source_stmt.include_column_options,
1749 &mut col_id_gen,
1750 create_source_type,
1751 overwrite_options.source_rate_limit,
1752 SqlColumnStrategy::FollowChecked,
1753 refresh_mode,
1754 )
1755 .await?;
1756
1757 let _ = Jvm::get_or_init()?;
1760
1761 let catalog_writer = session.catalog_writer()?;
1762 let action = match job_type {
1763 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1764 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1765 };
1766 let res = execute_with_long_running_notification(
1767 catalog_writer.create_iceberg_table(
1768 PbTableJobInfo {
1769 source,
1770 table: Some(table.to_prost()),
1771 fragment_graph: Some(graph),
1772 job_type: job_type as _,
1773 },
1774 PbSinkJobInfo {
1775 sink: Some(sink_catalog.to_proto()),
1776 fragment_graph: Some(sink_graph),
1777 },
1778 iceberg_source_catalog.to_prost(),
1779 if_not_exists,
1780 ),
1781 &session,
1782 "CREATE TABLE",
1783 action,
1784 )
1785 .await;
1786
1787 if res.is_err() {
1788 if table_created {
1791 let _ = iceberg_catalog
1792 .drop_table(&table_identifier)
1793 .await
1794 .inspect_err(|err| {
1795 tracing::error!(
1796 "failed to drop iceberg table {} after create iceberg engine table failed: {}",
1797 table_identifier,
1798 err.as_report()
1799 );
1800 });
1801 }
1802 res?
1803 }
1804
1805 Ok(())
1806}
1807
1808pub fn check_create_table_with_source(
1809 with_options: &WithOptions,
1810 format_encode: Option<FormatEncodeOptions>,
1811 include_column_options: &IncludeOption,
1812 cdc_table_info: &Option<CdcTableInfo>,
1813) -> Result<Option<FormatEncodeOptions>> {
1814 if cdc_table_info.is_some() {
1816 return Ok(format_encode);
1817 }
1818 let defined_source = with_options.is_source_connector();
1819
1820 if !include_column_options.is_empty() && !defined_source {
1821 return Err(ErrorCode::InvalidInputSyntax(
1822 "INCLUDE should be used with a connector".to_owned(),
1823 )
1824 .into());
1825 }
1826 if defined_source {
1827 format_encode.as_ref().ok_or_else(|| {
1828 ErrorCode::InvalidInputSyntax("Please specify a source schema using FORMAT".to_owned())
1829 })?;
1830 }
1831 Ok(format_encode)
1832}
1833
1834fn ensure_partition_columns_are_prefix_of_primary_key(
1835 partition_columns: &[String],
1836 primary_key_columns: &[String],
1837) -> std::result::Result<(), String> {
1838 if partition_columns.len() > primary_key_columns.len() {
1839 return Err("Partition columns cannot be longer than primary key columns.".to_owned());
1840 }
1841
1842 for (i, partition_col) in partition_columns.iter().enumerate() {
1843 if primary_key_columns.get(i) != Some(partition_col) {
1844 return Err(format!(
1845 "Partition column '{}' is not a prefix of the primary key.",
1846 partition_col
1847 ));
1848 }
1849 }
1850
1851 Ok(())
1852}
1853
1854#[allow(clippy::too_many_arguments)]
1855pub async fn generate_stream_graph_for_replace_table(
1856 _session: &Arc<SessionImpl>,
1857 table_name: ObjectName,
1858 original_catalog: &Arc<TableCatalog>,
1859 handler_args: HandlerArgs,
1860 statement: Statement,
1861 col_id_gen: ColumnIdGenerator,
1862 sql_column_strategy: SqlColumnStrategy,
1863) -> Result<(
1864 StreamFragmentGraph,
1865 TableCatalog,
1866 Option<SourceCatalog>,
1867 TableJobType,
1868)> {
1869 let Statement::CreateTable {
1870 columns,
1871 constraints,
1872 source_watermarks,
1873 append_only,
1874 on_conflict,
1875 with_version_columns,
1876 wildcard_idx,
1877 cdc_table_info,
1878 format_encode,
1879 include_column_options,
1880 engine,
1881 with_options,
1882 ..
1883 } = statement
1884 else {
1885 panic!("unexpected statement type: {:?}", statement);
1886 };
1887
1888 let format_encode = format_encode
1889 .clone()
1890 .map(|format_encode| format_encode.into_v2_with_warning());
1891
1892 let engine = match engine {
1893 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1894 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1895 };
1896
1897 let is_drop_connector =
1898 original_catalog.associated_source_id().is_some() && format_encode.is_none();
1899 if is_drop_connector {
1900 debug_assert!(
1901 include_column_options.is_empty()
1902 && with_options
1903 .iter()
1904 .all(|opt| opt.name.real_value().to_lowercase() != "connector")
1905 );
1906 }
1907
1908 let props = CreateTableProps {
1909 definition: handler_args.normalized_sql.clone(),
1910 append_only,
1911 on_conflict: on_conflict.into(),
1912 with_version_columns: with_version_columns
1913 .iter()
1914 .map(|col| col.real_value())
1915 .collect(),
1916 webhook_info: original_catalog.webhook_info.clone(),
1917 engine,
1918 };
1919
1920 let ((plan, mut source, mut table), job_type) = match (format_encode, cdc_table_info.as_ref()) {
1921 (Some(format_encode), None) => (
1922 gen_create_table_plan_with_source(
1923 handler_args,
1924 ExplainOptions::default(),
1925 table_name,
1926 columns,
1927 wildcard_idx,
1928 constraints,
1929 format_encode,
1930 source_watermarks,
1931 col_id_gen,
1932 include_column_options,
1933 props,
1934 sql_column_strategy,
1935 )
1936 .await?,
1937 TableJobType::General,
1938 ),
1939 (None, None) => {
1940 let context = OptimizerContext::from_handler_args(handler_args);
1941 let (plan, table) = gen_create_table_plan(
1942 context,
1943 table_name,
1944 columns,
1945 constraints,
1946 col_id_gen,
1947 source_watermarks,
1948 props,
1949 true,
1950 )?;
1951 ((plan, None, table), TableJobType::General)
1952 }
1953 (None, Some(cdc_table)) => {
1954 sanity_check_for_table_on_cdc_source(
1955 append_only,
1956 &columns,
1957 &wildcard_idx,
1958 &constraints,
1959 &source_watermarks,
1960 )?;
1961
1962 let session = &handler_args.session;
1963 let (source, resolved_table_name) =
1964 get_source_and_resolved_table_name(session, cdc_table.clone(), table_name.clone())?;
1965
1966 let (cdc_with_options, normalized_external_table_name) =
1967 derive_with_options_for_cdc_table(
1968 &source.with_properties,
1969 cdc_table.external_table_name.clone(),
1970 )?;
1971
1972 let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
1973 let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1974 cdc_with_options.clone(),
1975 &pk_names,
1976 ))
1977 .await?;
1978
1979 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1982
1983 let context: OptimizerContextRef =
1984 OptimizerContext::new(handler_args, ExplainOptions::default()).into();
1985 let (plan, table) = gen_create_table_plan_for_cdc_table(
1986 context,
1987 source,
1988 normalized_external_table_name,
1989 columns,
1990 source_watermarks,
1991 column_catalogs,
1992 pk_names,
1993 pk_comparisons,
1994 cdc_with_options,
1995 col_id_gen,
1996 on_conflict,
1997 with_version_columns
1998 .iter()
1999 .map(|col| col.real_value())
2000 .collect(),
2001 include_column_options,
2002 table_name,
2003 resolved_table_name,
2004 original_catalog.database_id,
2005 original_catalog.schema_id,
2006 original_catalog.id(),
2007 engine,
2008 )?;
2009
2010 ((plan, None, table), TableJobType::SharedCdcSource)
2011 }
2012 (Some(_), Some(_)) => {
2013 return Err(ErrorCode::NotSupported(
2014 "Data format and encoding format doesn't apply to table created from a CDC source"
2015 .into(),
2016 "Remove the FORMAT and ENCODE specification".into(),
2017 )
2018 .into());
2019 }
2020 };
2021
2022 if table.pk_column_ids() != original_catalog.pk_column_ids() {
2023 Err(ErrorCode::InvalidInputSyntax(
2024 "alter primary key of table is not supported".to_owned(),
2025 ))?
2026 }
2027
2028 let graph = build_graph(plan, Some(GraphJobType::Table))?;
2029
2030 table.id = original_catalog.id();
2032 if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2033 table.associated_source_id = Some(source_id);
2034
2035 let source = source.as_mut().unwrap();
2036 source.id = source_id;
2037 source.associated_table_id = Some(table.id());
2038 }
2039
2040 Ok((graph, table, source, job_type))
2041}
2042
2043fn get_source_and_resolved_table_name(
2044 session: &Arc<SessionImpl>,
2045 cdc_table: CdcTableInfo,
2046 table_name: ObjectName,
2047) -> Result<(Arc<SourceCatalog>, String)> {
2048 let db_name = &session.database();
2049 let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2050
2051 let (source_schema, source_name) =
2052 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2053
2054 let source = {
2055 let catalog_reader = session.env().catalog_reader().read_guard();
2056 let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2057 let (source, _) = catalog_reader.get_source_by_name(
2058 db_name,
2059 SchemaPath::Name(schema_name.as_str()),
2060 source_name.as_str(),
2061 )?;
2062 source.clone()
2063 };
2064 check_cdc_source_select_privilege(session, &source)?;
2065
2066 Ok((source, resolved_table_name))
2067}
2068
2069pub(crate) fn check_cdc_source_select_privilege(
2070 session: &SessionImpl,
2071 source: &SourceCatalog,
2072) -> Result<()> {
2073 session.check_privileges(&[ObjectCheckItem::new(
2074 source.owner,
2075 AclMode::Select,
2076 source.name.clone(),
2077 source.id,
2078 )])
2079}
2080
2081fn bind_webhook_info(
2083 session: &Arc<SessionImpl>,
2084 column_defs: &[ColumnDef],
2085 webhook_info: WebhookSourceInfo,
2086) -> Result<PbWebhookSourceInfo> {
2087 let WebhookSourceInfo {
2088 secret_ref,
2089 signature_expr,
2090 wait_for_persistence,
2091 is_batched,
2092 } = webhook_info;
2093
2094 for column in column_defs {
2095 for option_def in &column.options {
2096 match option_def.option {
2097 ColumnOption::Null => {}
2098 ColumnOption::GeneratedColumns(_) => {
2099 return Err(ErrorCode::InvalidInputSyntax(
2100 "generated columns are not supported for webhook tables".to_owned(),
2101 )
2102 .into());
2103 }
2104 ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2105 return Err(ErrorCode::InvalidInputSyntax(
2106 "default values are not supported for webhook tables".to_owned(),
2107 )
2108 .into());
2109 }
2110 ColumnOption::NotNull
2111 | ColumnOption::Unique { .. }
2112 | ColumnOption::ForeignKey { .. }
2113 | ColumnOption::Check(_)
2114 | ColumnOption::DialectSpecific(_) => {
2115 return Err(ErrorCode::InvalidInputSyntax(
2116 "only NULL column option is supported for webhook tables".to_owned(),
2117 )
2118 .into());
2119 }
2120 }
2121 }
2122 }
2123
2124 let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2126 let db_name = &session.database();
2127 let (schema_name, secret_name) =
2128 Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2129 let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2130 (
2131 Some(PbSecretRef {
2132 secret_id: secret_catalog.id,
2133 ref_as: match secret_ref.ref_as {
2134 SecretRefAsType::Text => PbRefAsType::Text,
2135 SecretRefAsType::File => PbRefAsType::File,
2136 }
2137 .into(),
2138 }),
2139 Some(secret_name),
2140 )
2141 } else {
2142 (None, None)
2143 };
2144
2145 let signature_expr = if let Some(signature_expr) = signature_expr {
2146 let payload_name = if column_defs.len() == 1
2147 && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
2148 {
2149 column_defs[0].name.real_value()
2150 } else {
2151 WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
2152 };
2153 let secure_compare_context = SecureCompareContext {
2154 payload_name,
2155 secret_name,
2156 };
2157 let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
2158 let expr = binder.bind_expr(&signature_expr)?;
2159
2160 if expr.as_function_call().is_none()
2162 || expr.as_function_call().unwrap().func_type()
2163 != crate::optimizer::plan_node::generic::ExprType::SecureCompare
2164 {
2165 return Err(ErrorCode::InvalidInputSyntax(
2166 "The signature verification function must be SECURE_COMPARE()".to_owned(),
2167 )
2168 .into());
2169 }
2170
2171 Some(expr.to_expr_proto())
2172 } else {
2173 session.notice_to_user(
2174 "VALIDATE clause is strongly recommended for safety or production usages",
2175 );
2176 None
2177 };
2178
2179 let pb_webhook_info = PbWebhookSourceInfo {
2180 secret_ref: pb_secret_ref,
2181 signature_expr,
2182 wait_for_persistence,
2183 is_batched,
2184 };
2185
2186 Ok(pb_webhook_info)
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191 use risingwave_common::catalog::{
2192 DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
2193 };
2194 use risingwave_common::types::StructType;
2195
2196 use super::*;
2197 use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
2198
2199 #[tokio::test]
2200 async fn test_iceberg_engine_primary_key_types() {
2201 let frontend = LocalFrontend::new(Default::default()).await;
2202 for (primary_key, append_only, pk_index, supported) in [
2203 ("id, period", false, false, false),
2204 ("id, items", false, false, false),
2205 ("id, mapping", false, false, false),
2206 ("id, f", false, false, false),
2207 ("id, d", false, false, false),
2208 ("id", false, false, true),
2209 ("id, ts", false, false, true),
2210 ("", false, false, true),
2211 ("id, period", true, false, true),
2212 ("id, period", false, true, true),
2213 ] {
2214 let pk_clause = if primary_key.is_empty() {
2215 String::new()
2216 } else {
2217 format!(", PRIMARY KEY ({primary_key})")
2218 };
2219 frontend
2220 .run_sql(&format!(
2221 "CREATE TABLE t (id INT, period STRUCT<start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ>, \
2222 items INT[], mapping MAP(INT, INT), f REAL, d DOUBLE PRECISION, ts TIMESTAMPTZ \
2223 {pk_clause}) {}",
2224 if append_only { "APPEND ONLY" } else { "" }
2225 ))
2226 .await
2227 .unwrap();
2228 let session = frontend.session_ref();
2229 let table = session
2230 .env()
2231 .catalog_reader()
2232 .read_guard()
2233 .get_created_table_by_name(
2234 DEFAULT_DATABASE_NAME,
2235 SchemaPath::Name(DEFAULT_SCHEMA_NAME),
2236 "t",
2237 )
2238 .unwrap()
2239 .0
2240 .clone();
2241 let pks = table
2242 .pk_column_names()
2243 .into_iter()
2244 .map(str::to_owned)
2245 .collect_vec();
2246 for write_mode in ["merge-on-read", "copy-on-write"] {
2247 if write_mode == "copy-on-write" && (append_only || pk_index) {
2248 continue;
2249 }
2250 let options = BTreeMap::from([
2251 ("connector".to_owned(), "iceberg".to_owned()),
2252 ("catalog.type".to_owned(), "storage".to_owned()),
2253 (
2254 "warehouse.path".to_owned(),
2255 "s3://test/warehouse".to_owned(),
2256 ),
2257 ("database.name".to_owned(), "public".to_owned()),
2258 ("table.name".to_owned(), "t".to_owned()),
2259 ("write_mode".to_owned(), write_mode.to_owned()),
2260 ("enable_pk_index".to_owned(), pk_index.to_string()),
2261 ]);
2262 let result = build_iceberg_engine_sink_options(
2263 options,
2264 &WithOptions::default(),
2265 &table,
2266 &pks,
2267 );
2268 if supported {
2269 result.unwrap();
2270 } else {
2271 let err = result.unwrap_err().to_report_string();
2272 let column = primary_key.split(", ").last().unwrap();
2273 assert!(
2274 err.contains(&format!(
2275 "Iceberg engine table primary key column \"{column}\" has unsupported type"
2276 )),
2277 "{write_mode}: {err}"
2278 );
2279 }
2280 }
2281 frontend.run_sql("DROP TABLE t").await.unwrap();
2282 }
2283 }
2284
2285 #[tokio::test]
2286 async fn test_cdc_relations_require_select_privilege_on_source() {
2287 let frontend = LocalFrontend::new(Default::default()).await;
2288 frontend
2291 .run_sql(
2292 r#"
2293 CREATE SOURCE cdc_source WITH (
2294 connector = 'postgres-cdc',
2295 hostname = 'localhost',
2296 port = '5432',
2297 username = 'postgres',
2298 password = '',
2299 database.name = 'db'
2300 ) FORMAT PLAIN ENCODE JSON
2301 "#,
2302 )
2303 .await
2304 .unwrap();
2305 frontend.run_sql("CREATE USER cdc_user").await.unwrap();
2306 frontend
2307 .run_sql("GRANT CREATE ON SCHEMA public TO cdc_user")
2308 .await
2309 .unwrap();
2310
2311 let user_id = frontend
2312 .session_ref()
2313 .env()
2314 .user_info_reader()
2315 .read_guard()
2316 .get_user_by_name("cdc_user")
2317 .unwrap()
2318 .id;
2319 let user_session = frontend.session_user_ref(
2320 DEFAULT_DATABASE_NAME.to_owned(),
2321 "cdc_user".to_owned(),
2322 user_id,
2323 );
2324 let create_table =
2325 "CREATE TABLE cdc_table (id INT PRIMARY KEY) FROM cdc_source TABLE 'public.t'";
2326 let create_table_source = "CREATE SOURCE cdc_table_source (id INT PRIMARY KEY) \
2327 WITH (snapshot = 'false') FROM cdc_source TABLE 'public.t'";
2328
2329 for sql in [create_table, create_table_source] {
2330 let err = frontend
2331 .run_sql_with_session(user_session.clone(), sql)
2332 .await
2333 .unwrap_err();
2334 assert!(
2335 err.to_string()
2336 .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2337 "{err:?}"
2338 );
2339 }
2340
2341 frontend
2342 .run_sql("GRANT SELECT ON SOURCE cdc_source TO cdc_user")
2343 .await
2344 .unwrap();
2345 frontend
2346 .run_sql_with_session(user_session.clone(), create_table)
2347 .await
2348 .unwrap();
2349 frontend
2350 .run_sql_with_session(user_session.clone(), create_table_source)
2351 .await
2352 .unwrap();
2353
2354 let table_source = {
2355 let catalog_reader = frontend.session_ref().env().catalog_reader().read_guard();
2356 catalog_reader
2357 .get_source_by_name(
2358 DEFAULT_DATABASE_NAME,
2359 SchemaPath::Name("public"),
2360 "cdc_table_source",
2361 )
2362 .unwrap()
2363 .0
2364 .clone()
2365 };
2366 assert_eq!(table_source.with_properties.len(), 2);
2367 assert_eq!(
2368 table_source.with_properties.get_connector().as_deref(),
2369 Some("postgres-cdc")
2370 );
2371 assert_eq!(
2372 table_source
2373 .with_properties
2374 .get("snapshot")
2375 .map(String::as_str),
2376 Some("false")
2377 );
2378 assert!(table_source.with_properties.as_secret().is_empty());
2379 let external_table = table_source.info.external_table.as_ref().unwrap();
2383 assert!(!external_table.connect_properties.is_empty());
2384
2385 let err = frontend
2386 .run_sql_with_session(
2387 user_session.clone(),
2388 "SELECT * FROM postgres_query('cdc_table_source', 'SELECT 1')",
2389 )
2390 .await
2391 .unwrap_err();
2392 assert!(
2393 err.to_report_string()
2394 .contains("only accept shared CDC sources, not CDC table sources"),
2395 "{err:?}"
2396 );
2397
2398 frontend
2399 .run_sql("REVOKE SELECT ON SOURCE cdc_source FROM cdc_user")
2400 .await
2401 .unwrap();
2402 let err = frontend
2403 .run_sql_with_session(user_session, "ALTER TABLE cdc_table ADD COLUMN value INT")
2404 .await
2405 .unwrap_err();
2406 assert!(
2407 err.to_string()
2408 .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2409 "{err:?}"
2410 );
2411 }
2412
2413 #[tokio::test]
2414 async fn test_create_table_handler() {
2415 let sql =
2416 "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
2417 let frontend = LocalFrontend::new(Default::default()).await;
2418 frontend.run_sql(sql).await.unwrap();
2419
2420 let session = frontend.session_ref();
2421 let catalog_reader = session.env().catalog_reader().read_guard();
2422 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2423
2424 let (table, _) = catalog_reader
2426 .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2427 .unwrap();
2428 assert_eq!(table.name(), "t");
2429
2430 let columns = table
2431 .columns
2432 .iter()
2433 .map(|col| (col.name(), col.data_type().clone()))
2434 .collect::<HashMap<&str, DataType>>();
2435
2436 let expected_columns = maplit::hashmap! {
2437 ROW_ID_COLUMN_NAME => DataType::Serial,
2438 "v1" => DataType::Int16,
2439 "v2" => StructType::new(
2440 vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
2441 )
2442 .with_ids([3, 4, 5].map(ColumnId::new))
2443 .into(),
2444 RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
2445 };
2446
2447 assert_eq!(columns, expected_columns, "{columns:#?}");
2448 }
2449
2450 #[tokio::test]
2451 async fn test_create_webhook_table_with_arbitrary_columns() {
2452 let frontend = LocalFrontend::new(Default::default()).await;
2453 frontend
2454 .run_sql("create schema ingest_schema;")
2455 .await
2456 .unwrap();
2457 frontend
2458 .run_sql(
2459 r#"
2460 create table ingest_schema.orders (
2461 id int,
2462 customer_name varchar,
2463 amount double precision,
2464 primary key (id)
2465 ) with (
2466 connector = 'webhook'
2467 ) validate as secure_compare(
2468 headers->>'x-rw-signature',
2469 'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
2470 );
2471 "#,
2472 )
2473 .await
2474 .unwrap();
2475
2476 let session = frontend.session_ref();
2477 let catalog_reader = session.env().catalog_reader().read_guard();
2478 let (table, _) = catalog_reader
2479 .get_created_table_by_name(
2480 DEFAULT_DATABASE_NAME,
2481 SchemaPath::Name("ingest_schema"),
2482 "orders",
2483 )
2484 .unwrap();
2485
2486 assert!(table.webhook_info.is_some());
2487 assert_eq!(
2488 table
2489 .columns
2490 .iter()
2491 .filter(|column| column.can_dml())
2492 .count(),
2493 3
2494 );
2495 }
2496
2497 #[tokio::test]
2498 async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
2499 let frontend = LocalFrontend::new(Default::default()).await;
2500 frontend
2501 .run_sql(
2502 r#"
2503 create table webhook_single_column (
2504 body jsonb
2505 ) with (
2506 connector = 'webhook'
2507 ) validate as secure_compare(
2508 headers->>'x-rw-signature',
2509 'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
2510 );
2511 "#,
2512 )
2513 .await
2514 .unwrap();
2515 }
2516
2517 #[tokio::test]
2518 async fn test_create_webhook_table_with_generated_columns() {
2519 let frontend = LocalFrontend::new(Default::default()).await;
2520 let err = frontend
2521 .run_sql(
2522 r#"
2523 create table webhook_generated_columns (
2524 id int,
2525 amount double precision,
2526 amount_with_fee double precision as amount + 1.0
2527 ) with (
2528 connector = 'webhook'
2529 );
2530 "#,
2531 )
2532 .await
2533 .unwrap_err();
2534
2535 assert!(
2536 err.to_string()
2537 .contains("generated columns are not supported for webhook tables"),
2538 "{err:?}"
2539 );
2540 }
2541
2542 #[tokio::test]
2543 async fn test_create_webhook_table_with_default_value() {
2544 let frontend = LocalFrontend::new(Default::default()).await;
2545 let err = frontend
2546 .run_sql(
2547 r#"
2548 create table webhook_default_value (
2549 id int default 42,
2550 amount double precision
2551 ) with (
2552 connector = 'webhook'
2553 );
2554 "#,
2555 )
2556 .await
2557 .unwrap_err();
2558
2559 assert!(
2560 err.to_string()
2561 .contains("default values are not supported for webhook tables"),
2562 "{err:?}"
2563 );
2564 }
2565
2566 #[tokio::test]
2567 async fn test_create_webhook_table_with_not_null_option() {
2568 let frontend = LocalFrontend::new(Default::default()).await;
2569 let err = frontend
2570 .run_sql(
2571 r#"
2572 create table webhook_not_null (
2573 id int not null,
2574 amount double precision
2575 ) with (
2576 connector = 'webhook'
2577 );
2578 "#,
2579 )
2580 .await
2581 .unwrap_err();
2582
2583 assert!(
2584 err.to_string()
2585 .contains("only NULL column option is supported for webhook tables"),
2586 "{err:?}"
2587 );
2588 }
2589
2590 #[test]
2591 fn test_bind_primary_key() {
2592 for (sql, expected) in [
2595 ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
2596 ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
2597 ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
2598 (
2599 "create table t (v1 int primary key, v2 int primary key)",
2600 Err("multiple primary keys are not allowed"),
2601 ),
2602 (
2603 "create table t (v1 int primary key primary key, v2 int)",
2604 Err("multiple primary keys are not allowed"),
2605 ),
2606 (
2607 "create table t (v1 int, v2 int, primary key (v1))",
2608 Ok(&[1]),
2609 ),
2610 (
2611 "create table t (v1 int, primary key (v2), v2 int)",
2612 Ok(&[2]),
2613 ),
2614 (
2615 "create table t (primary key (v2, v1), v1 int, v2 int)",
2616 Ok(&[2, 1]),
2617 ),
2618 (
2619 "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
2620 Err("multiple primary keys are not allowed"),
2621 ),
2622 (
2623 "create table t (v1 int primary key, primary key (v1), v2 int)",
2624 Err("multiple primary keys are not allowed"),
2625 ),
2626 (
2627 "create table t (v1 int, primary key (V3), v2 int)",
2628 Err("column \"v3\" named in key does not exist"),
2629 ),
2630 ] {
2631 let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
2632 let risingwave_sqlparser::ast::Statement::CreateTable {
2633 columns: column_defs,
2634 constraints,
2635 ..
2636 } = ast.remove(0)
2637 else {
2638 panic!("test case should be create table")
2639 };
2640 let actual: Result<_> = (|| {
2641 let mut columns = bind_sql_columns(&column_defs, false)?;
2642 let mut col_id_gen = ColumnIdGenerator::new_initial();
2643 for c in &mut columns {
2644 col_id_gen.generate(c)?;
2645 }
2646
2647 let pk_names =
2648 bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
2649 let (_, pk_column_ids, _) =
2650 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
2651 Ok(pk_column_ids)
2652 })();
2653 match (expected, actual) {
2654 (Ok(expected), Ok(actual)) => assert_eq!(
2655 expected.iter().copied().map(ColumnId::new).collect_vec(),
2656 actual,
2657 "sql: {sql}"
2658 ),
2659 (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
2660 (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
2661 (Err(expected), Err(actual)) => assert!(
2662 actual.to_string().contains(expected),
2663 "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
2664 ),
2665 }
2666 }
2667 }
2668
2669 #[tokio::test]
2670 async fn test_duplicate_props_options() {
2671 let proto_file = create_proto_file(PROTO_FILE_DATA);
2672 let sql = format!(
2673 r#"CREATE TABLE t
2674 WITH (
2675 connector = 'kinesis',
2676 aws.region='user_test_topic',
2677 endpoint='172.10.1.1:9090,172.10.1.2:9090',
2678 aws.credentials.access_key_id = 'your_access_key_1',
2679 aws.credentials.secret_access_key = 'your_secret_key_1'
2680 )
2681 FORMAT PLAIN ENCODE PROTOBUF (
2682 message = '.test.TestRecord',
2683 aws.credentials.access_key_id = 'your_access_key_2',
2684 aws.credentials.secret_access_key = 'your_secret_key_2',
2685 schema.location = 'file://{}',
2686 )"#,
2687 proto_file.path().to_str().unwrap()
2688 );
2689 let frontend = LocalFrontend::new(Default::default()).await;
2690 frontend.run_sql(sql).await.unwrap();
2691
2692 let session = frontend.session_ref();
2693 let catalog_reader = session.env().catalog_reader().read_guard();
2694 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2695
2696 let (source, _) = catalog_reader
2698 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2699 .unwrap();
2700 assert_eq!(source.name, "t");
2701
2702 assert_eq!(
2704 source
2705 .info
2706 .format_encode_options
2707 .get("aws.credentials.access_key_id")
2708 .unwrap(),
2709 "your_access_key_2"
2710 );
2711 assert_eq!(
2712 source
2713 .info
2714 .format_encode_options
2715 .get("aws.credentials.secret_access_key")
2716 .unwrap(),
2717 "your_secret_key_2"
2718 );
2719
2720 assert_eq!(
2722 source
2723 .with_properties
2724 .get("aws.credentials.access_key_id")
2725 .unwrap(),
2726 "your_access_key_1"
2727 );
2728 assert_eq!(
2729 source
2730 .with_properties
2731 .get("aws.credentials.secret_access_key")
2732 .unwrap(),
2733 "your_secret_key_1"
2734 );
2735
2736 assert!(!source.with_properties.contains_key("schema.location"));
2738 }
2739}