1use std::collections::{BTreeMap, HashMap, HashSet};
16use std::rc::Rc;
17use std::sync::Arc;
18
19use anyhow::{Context, anyhow};
20use clap::ValueEnum;
21use either::Either;
22use fancy_regex::Regex;
23use fixedbitset::FixedBitSet;
24use itertools::Itertools;
25use percent_encoding::percent_decode_str;
26use pgwire::pg_response::{PgResponse, StatementType};
27use prost::Message as _;
28use risingwave_common::catalog::{
29 CdcTableDesc, ColumnCatalog, ColumnDesc, ConflictBehavior, DEFAULT_SCHEMA_NAME, Engine,
30 ICEBERG_SINK_PREFIX, ICEBERG_SOURCE_PREFIX, RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME,
31 TableId,
32};
33use risingwave_common::config::MetaBackend;
34use risingwave_common::global_jvm::Jvm;
35use risingwave_common::session_config::sink_decouple::SinkDecouple;
36use risingwave_common::util::sort_util::{ColumnOrder, OrderType};
37use risingwave_common::util::value_encoding::DatumToProtoExt;
38use risingwave_common::{bail, bail_not_implemented};
39use risingwave_connector::sink::decouple_checkpoint_log_sink::COMMIT_CHECKPOINT_INTERVAL;
40use risingwave_connector::source::cdc::external::{
41 DATABASE_NAME_KEY, ExternalCdcTableType, ExternalTableConfig, ExternalTableImpl,
42 SCHEMA_NAME_KEY, SchemaTableName, TABLE_NAME_KEY,
43};
44use risingwave_connector::source::cdc::{
45 build_cdc_table_id, normalize_simple_postgres_quoted_table_name,
46};
47use risingwave_connector::{
48 AUTO_SCHEMA_CHANGE_KEY, WithOptionsSecResolved, WithPropertiesExt, source,
49};
50use risingwave_pb::catalog::connection::Info as ConnectionInfo;
51use risingwave_pb::catalog::connection_params::ConnectionType;
52use risingwave_pb::catalog::{PbSource, PbWebhookSourceInfo, WatermarkDesc};
53use risingwave_pb::ddl_service::{PbTableJobType, TableJobType};
54use risingwave_pb::plan_common::column_desc::GeneratedOrDefaultColumn;
55use risingwave_pb::plan_common::{
56 AdditionalColumn, ColumnDescVersion, DefaultColumnDesc, GeneratedColumnDesc,
57};
58use risingwave_pb::secret::PbSecretRef;
59use risingwave_pb::secret::secret_ref::PbRefAsType;
60use risingwave_pb::stream_plan::StreamFragmentGraph;
61use risingwave_sqlparser::ast::{
62 CdcTableInfo, ColumnDef, ColumnOption, CompatibleFormatEncode, ConnectionRefValue, CreateSink,
63 CreateSinkStatement, CreateSourceStatement, DataType as AstDataType, ExplainOptions, Format,
64 FormatEncodeOptions, Ident, ObjectName, OnConflict, SecretRefAsType, SourceWatermark,
65 Statement, TableConstraint, WebhookSourceInfo, WithProperties,
66};
67use risingwave_sqlparser::parser::IncludeOption;
68use thiserror_ext::AsReport;
69
70use super::RwPgResponse;
71use super::create_source::{CreateSourceType, SqlColumnStrategy, bind_columns_from_source};
72use crate::binder::{Clause, SecureCompareContext, WEBHOOK_PAYLOAD_FIELD_NAME, bind_data_type};
73use crate::catalog::root_catalog::SchemaPath;
74use crate::catalog::source_catalog::SourceCatalog;
75use crate::catalog::table_catalog::TableVersion;
76use crate::catalog::{ColumnId, DatabaseId, SchemaId, SourceId, check_column_name_not_reserved};
77use crate::error::{ErrorCode, Result, RwError, bail_bind_error};
78use crate::expr::{Expr, ExprImpl, ExprRewriter};
79use crate::handler::HandlerArgs;
80use crate::handler::create_source::{
81 UPSTREAM_SOURCE_KEY, bind_connector_props, bind_create_source_or_table_with_connector,
82 bind_source_watermark, handle_addition_columns,
83};
84use crate::handler::util::{
85 LongRunningNotificationAction, SourceSchemaCompatExt, execute_with_long_running_notification,
86};
87use crate::optimizer::plan_node::generic::{SourceNodeKind, build_cdc_scan_options_with_options};
88use crate::optimizer::plan_node::{
89 LogicalCdcScan, LogicalPlanRef, LogicalSource, StreamPlanRef as PlanRef,
90 ensure_sync_log_store_fragment_root,
91};
92use crate::optimizer::property::{Order, RequiredDist};
93use crate::optimizer::{OptimizerContext, OptimizerContextRef, PlanRoot};
94use crate::session::SessionImpl;
95use crate::session::current::notice_to_user;
96use crate::stream_fragmenter::{GraphJobType, build_graph};
97use crate::utils::OverwriteOptions;
98use crate::{Binder, Explain, TableCatalog, WithOptions};
99
100mod col_id_gen;
101pub use col_id_gen::*;
102use risingwave_connector::sink::SinkParam;
103use risingwave_connector::sink::iceberg::{
104 COMPACTION_DELETE_FILES_COUNT_THRESHOLD, COMPACTION_INTERVAL_SEC, COMPACTION_MAX_SNAPSHOTS_NUM,
105 COMPACTION_SMALL_FILES_THRESHOLD_MB, COMPACTION_TARGET_FILE_SIZE_MB,
106 COMPACTION_TRIGGER_SNAPSHOT_COUNT, COMPACTION_TYPE, COMPACTION_WRITE_PARQUET_COMPRESSION,
107 COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES, COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS,
108 CompactionType, ENABLE_COMPACTION, ENABLE_PK_INDEX, ENABLE_SNAPSHOT_EXPIRATION, FORMAT_VERSION,
109 ICEBERG_WRITE_MODE_COPY_ON_WRITE, ICEBERG_WRITE_MODE_MERGE_ON_READ, IcebergSink,
110 IcebergWriteMode, ORDER_KEY, SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES,
111 SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA, SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS,
112 SNAPSHOT_EXPIRATION_RETAIN_LAST, WRITE_MODE, parse_partition_by_exprs,
113 validate_order_key_columns,
114};
115use risingwave_pb::ddl_service::create_iceberg_table_request::{PbSinkJobInfo, PbTableJobInfo};
116
117use crate::handler::create_sink::{SinkPlanContext, gen_sink_plan};
118
119fn ensure_column_options_supported(c: &ColumnDef) -> Result<()> {
120 for option_def in &c.options {
121 match option_def.option {
122 ColumnOption::GeneratedColumns(_) => {}
123 ColumnOption::DefaultValue(_) => {}
124 ColumnOption::DefaultValueInternal { .. } => {}
125 ColumnOption::Unique { is_primary: true } => {}
126 ColumnOption::Null => {}
127 ColumnOption::NotNull => {}
128 _ => bail_not_implemented!("column constraints \"{}\"", option_def),
129 }
130 }
131 Ok(())
132}
133
134pub fn bind_sql_columns(
138 column_defs: &[ColumnDef],
139 is_for_drop_table_connector: bool,
140) -> Result<Vec<ColumnCatalog>> {
141 let mut columns = Vec::with_capacity(column_defs.len());
142
143 for column in column_defs {
144 ensure_column_options_supported(column)?;
145 let ColumnDef {
149 name,
150 data_type,
151 collation,
152 options,
153 ..
154 } = column;
155
156 let data_type = data_type
157 .clone()
158 .ok_or_else(|| ErrorCode::InvalidInputSyntax("data type is not specified".into()))?;
159 if let Some(collation) = collation {
160 if !["C", "POSIX"].contains(&collation.real_value().as_str()) {
166 bail_not_implemented!(
167 "Collate collation other than `C` or `POSIX` is not implemented"
168 );
169 }
170
171 match data_type {
172 AstDataType::Text | AstDataType::Varchar | AstDataType::Char(_) => {}
173 _ => {
174 return Err(ErrorCode::NotSupported(
175 format!("{} is not a collatable data type", data_type),
176 "The only built-in collatable data types are `varchar`, please check your type".into(),
177 ).into());
178 }
179 }
180 }
181
182 if !is_for_drop_table_connector {
183 check_column_name_not_reserved(&name.real_value())?;
187 }
188
189 let nullable: bool = !options
190 .iter()
191 .any(|def| matches!(def.option, ColumnOption::NotNull));
192
193 columns.push(ColumnCatalog {
194 column_desc: ColumnDesc {
195 data_type: bind_data_type(&data_type)?,
196 column_id: ColumnId::placeholder(),
197 name: name.real_value(),
198 generated_or_default_column: None,
199 description: None,
200 additional_column: AdditionalColumn { column_type: None },
201 version: ColumnDescVersion::LATEST,
202 system_column: None,
203 nullable,
204 },
205 is_hidden: false,
206 });
207 }
208
209 Ok(columns)
210}
211
212fn check_generated_column_constraints(
213 column_name: &String,
214 column_id: ColumnId,
215 expr: &ExprImpl,
216 column_catalogs: &[ColumnCatalog],
217 generated_column_names: &[String],
218 pk_column_ids: &[ColumnId],
219) -> Result<()> {
220 let input_refs = expr.collect_input_refs(column_catalogs.len());
221 for idx in input_refs.ones() {
222 let referred_generated_column = &column_catalogs[idx].column_desc.name;
223 if generated_column_names
224 .iter()
225 .any(|c| c == referred_generated_column)
226 {
227 return Err(ErrorCode::BindError(format!(
228 "Generated can not reference another generated column. \
229 But here generated column \"{}\" referenced another generated column \"{}\"",
230 column_name, referred_generated_column
231 ))
232 .into());
233 }
234 }
235
236 if pk_column_ids.contains(&column_id) && expr.is_impure() {
237 return Err(ErrorCode::BindError(format!(
238 "Generated columns with impure expressions should not be part of the primary key. \
239 Here column \"{}\" is defined as part of the primary key.",
240 column_name
241 ))
242 .into());
243 }
244
245 Ok(())
246}
247
248pub fn bind_sql_column_constraints(
251 session: &SessionImpl,
252 table_name: String,
253 column_catalogs: &mut [ColumnCatalog],
254 columns: &[ColumnDef],
255 pk_column_ids: &[ColumnId],
256) -> Result<()> {
257 let generated_column_names = {
258 let mut names = vec![];
259 for column in columns {
260 for option_def in &column.options {
261 if let ColumnOption::GeneratedColumns(_) = option_def.option {
262 names.push(column.name.real_value());
263 break;
264 }
265 }
266 }
267 names
268 };
269
270 let mut binder = Binder::new_for_ddl(session);
271 binder.bind_columns_to_context(table_name, column_catalogs)?;
272
273 for column in columns {
274 let Some(idx) = column_catalogs
275 .iter()
276 .position(|c| c.name() == column.name.real_value())
277 else {
278 continue;
281 };
282
283 for option_def in &column.options {
284 match &option_def.option {
285 ColumnOption::GeneratedColumns(expr) => {
286 binder.set_clause(Some(Clause::GeneratedColumn));
287
288 let expr_impl = binder.bind_expr(expr).with_context(|| {
289 format!(
290 "fail to bind expression in generated column \"{}\"",
291 column.name.real_value()
292 )
293 })?;
294
295 check_generated_column_constraints(
296 &column.name.real_value(),
297 column_catalogs[idx].column_id(),
298 &expr_impl,
299 column_catalogs,
300 &generated_column_names,
301 pk_column_ids,
302 )?;
303
304 column_catalogs[idx].column_desc.generated_or_default_column = Some(
305 GeneratedOrDefaultColumn::GeneratedColumn(GeneratedColumnDesc {
306 expr: Some(expr_impl.to_expr_proto()),
307 }),
308 );
309 binder.set_clause(None);
310 }
311 ColumnOption::DefaultValue(expr) => {
312 let expr_impl = binder
313 .bind_expr(expr)?
314 .cast_assign(column_catalogs[idx].data_type())?;
315
316 let rewritten_expr_impl = session
324 .pinned_snapshot()
325 .inline_now_proc_time()
326 .rewrite_expr(expr_impl.clone());
327
328 if let Some(snapshot_value) = rewritten_expr_impl.try_fold_const() {
329 let snapshot_value = snapshot_value?;
330
331 column_catalogs[idx].column_desc.generated_or_default_column =
332 Some(GeneratedOrDefaultColumn::DefaultColumn(DefaultColumnDesc {
333 snapshot_value: Some(snapshot_value.to_protobuf()),
334 expr: Some(expr_impl.to_expr_proto()),
335 }));
337 } else {
338 return Err(ErrorCode::BindError(format!(
339 "Default expression used in column `{}` cannot be evaluated. \
340 Use generated columns instead if you mean to reference other columns.",
341 column.name
342 ))
343 .into());
344 }
345 }
346 ColumnOption::DefaultValueInternal { persisted, expr: _ } => {
347 if persisted.is_empty() {
352 bail_bind_error!(
353 "DEFAULT INTERNAL is only used for internal purposes, \
354 please specify a concrete default value"
355 );
356 }
357
358 let desc = DefaultColumnDesc::decode(&**persisted)
359 .expect("failed to decode persisted `DefaultColumnDesc`");
360
361 column_catalogs[idx].column_desc.generated_or_default_column =
362 Some(GeneratedOrDefaultColumn::DefaultColumn(desc));
363 }
364 _ => {}
365 }
366 }
367 }
368 Ok(())
369}
370
371pub fn bind_table_constraints(table_constraints: &[TableConstraint]) -> Result<Vec<String>> {
373 let mut pk_column_names = vec![];
374
375 for constraint in table_constraints {
376 match constraint {
377 TableConstraint::Unique {
378 name: _,
379 columns,
380 is_primary: true,
381 } => {
382 if !pk_column_names.is_empty() {
383 return Err(multiple_pk_definition_err());
384 }
385 pk_column_names = columns.iter().map(|c| c.real_value()).collect_vec();
386 }
387 _ => bail_not_implemented!("table constraint \"{}\"", constraint),
388 }
389 }
390 Ok(pk_column_names)
391}
392
393pub fn bind_sql_pk_names(
394 columns_defs: &[ColumnDef],
395 pk_names_from_table_constraints: Vec<String>,
396) -> Result<Vec<String>> {
397 let mut pk_column_names = pk_names_from_table_constraints;
398
399 for column in columns_defs {
400 for option_def in &column.options {
401 if let ColumnOption::Unique { is_primary: true } = option_def.option {
402 if !pk_column_names.is_empty() {
403 return Err(multiple_pk_definition_err());
404 }
405 pk_column_names.push(column.name.real_value());
406 };
407 }
408 }
409
410 Ok(pk_column_names)
411}
412
413fn multiple_pk_definition_err() -> RwError {
414 ErrorCode::BindError("multiple primary keys are not allowed".into()).into()
415}
416
417pub fn bind_pk_and_row_id_on_relation(
422 mut columns: Vec<ColumnCatalog>,
423 pk_names: Vec<String>,
424 must_need_pk: bool,
425) -> Result<(Vec<ColumnCatalog>, Vec<ColumnId>, Option<usize>)> {
426 for c in &columns {
427 assert!(c.column_id() != ColumnId::placeholder());
428 }
429
430 let name_to_id = columns
432 .iter()
433 .map(|c| (c.name(), c.column_id()))
434 .collect::<HashMap<_, _>>();
435
436 let mut pk_column_ids: Vec<_> = pk_names
437 .iter()
438 .map(|name| {
439 name_to_id.get(name.as_str()).copied().ok_or_else(|| {
440 ErrorCode::BindError(format!("column \"{name}\" named in key does not exist"))
441 })
442 })
443 .try_collect()?;
444
445 let need_row_id = pk_column_ids.is_empty() && must_need_pk;
447
448 let row_id_index = need_row_id.then(|| {
449 let column = ColumnCatalog::row_id_column();
450 let index = columns.len();
451 pk_column_ids = vec![column.column_id()];
452 columns.push(column);
453 index
454 });
455
456 if let Some(col) = columns.iter().map(|c| c.name()).duplicates().next() {
457 Err(ErrorCode::InvalidInputSyntax(format!(
458 "column \"{col}\" specified more than once"
459 )))?;
460 }
461
462 Ok((columns, pk_column_ids, row_id_index))
463}
464
465#[allow(clippy::too_many_arguments)]
468pub(crate) async fn gen_create_table_plan_with_source(
469 mut handler_args: HandlerArgs,
470 explain_options: ExplainOptions,
471 table_name: ObjectName,
472 column_defs: Vec<ColumnDef>,
473 wildcard_idx: Option<usize>,
474 constraints: Vec<TableConstraint>,
475 format_encode: FormatEncodeOptions,
476 source_watermarks: Vec<SourceWatermark>,
477 mut col_id_gen: ColumnIdGenerator,
478 include_column_options: IncludeOption,
479 props: CreateTableProps,
480 sql_column_strategy: SqlColumnStrategy,
481) -> Result<(PlanRef, Option<SourceCatalog>, TableCatalog)> {
482 if props.append_only
483 && format_encode.format != Format::Plain
484 && format_encode.format != Format::Native
485 {
486 return Err(ErrorCode::BindError(format!(
487 "Append only table does not support format {}.",
488 format_encode.format
489 ))
490 .into());
491 }
492
493 let session = &handler_args.session;
494 let (with_properties, refresh_mode) =
495 bind_connector_props(&handler_args, &format_encode, false)?;
496 if with_properties.is_shareable_cdc_connector() {
497 generated_columns_check_for_cdc_table(&column_defs)?;
498 not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
499 } else if column_defs.iter().any(|col| {
500 col.options
501 .iter()
502 .any(|def| matches!(def.option, ColumnOption::NotNull))
503 }) {
504 notice_to_user(
506 "The table contains columns with NOT NULL constraints. Any rows from upstream violating the constraints will be ignored silently.",
507 );
508 }
509
510 let db_name: &str = &session.database();
511 let (schema_name, _) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
512
513 let (columns_from_resolve_source, source_info) = bind_columns_from_source(
515 session,
516 &format_encode,
517 Either::Left(&with_properties),
518 CreateSourceType::Table,
519 )
520 .await?;
521
522 let overwrite_options = OverwriteOptions::new(&mut handler_args);
523 let rate_limit = overwrite_options.source_rate_limit;
524 let source = bind_create_source_or_table_with_connector(
525 handler_args.clone(),
526 table_name,
527 format_encode,
528 with_properties,
529 &column_defs,
530 constraints,
531 wildcard_idx,
532 source_watermarks,
533 columns_from_resolve_source,
534 source_info,
535 include_column_options,
536 &mut col_id_gen,
537 CreateSourceType::Table,
538 rate_limit,
539 sql_column_strategy,
540 refresh_mode,
541 )
542 .await?;
543
544 let context = OptimizerContext::new(handler_args, explain_options);
545
546 let (plan, table) = gen_table_plan_with_source(
547 context.into(),
548 schema_name,
549 source.clone(),
550 col_id_gen.into_version(),
551 props,
552 )?;
553
554 Ok((plan, Some(source), table))
555}
556
557#[allow(clippy::too_many_arguments)]
560pub(crate) fn gen_create_table_plan(
561 context: OptimizerContext,
562 table_name: ObjectName,
563 column_defs: Vec<ColumnDef>,
564 constraints: Vec<TableConstraint>,
565 mut col_id_gen: ColumnIdGenerator,
566 source_watermarks: Vec<SourceWatermark>,
567 props: CreateTableProps,
568 is_for_replace_plan: bool,
569) -> Result<(PlanRef, TableCatalog)> {
570 let mut columns = bind_sql_columns(&column_defs, is_for_replace_plan)?;
571 for c in &mut columns {
572 col_id_gen.generate(c)?;
573 }
574
575 let (_, secret_refs, connection_refs) = context.with_options().clone().into_parts();
576 if !secret_refs.is_empty() || !connection_refs.is_empty() {
577 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());
578 }
579
580 gen_create_table_plan_without_source(
581 context,
582 table_name,
583 columns,
584 column_defs,
585 constraints,
586 source_watermarks,
587 col_id_gen.into_version(),
588 props,
589 )
590}
591
592#[allow(clippy::too_many_arguments)]
593pub(crate) fn gen_create_table_plan_without_source(
594 context: OptimizerContext,
595 table_name: ObjectName,
596 columns: Vec<ColumnCatalog>,
597 column_defs: Vec<ColumnDef>,
598 constraints: Vec<TableConstraint>,
599 source_watermarks: Vec<SourceWatermark>,
600 version: TableVersion,
601 props: CreateTableProps,
602) -> Result<(PlanRef, TableCatalog)> {
603 let pk_names = bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
605 let (mut columns, pk_column_ids, row_id_index) =
606 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
607
608 let watermark_descs = bind_source_watermark(
609 context.session_ctx(),
610 table_name.real_value(),
611 source_watermarks,
612 &columns,
613 )?;
614
615 bind_sql_column_constraints(
616 context.session_ctx(),
617 table_name.real_value(),
618 &mut columns,
619 &column_defs,
620 &pk_column_ids,
621 )?;
622 let session = context.session_ctx().clone();
623
624 let db_name = &session.database();
625 let (schema_name, table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
626
627 let info = CreateTableInfo {
628 columns,
629 pk_column_ids,
630 row_id_index,
631 watermark_descs,
632 source_catalog: None,
633 version,
634 };
635
636 gen_table_plan_inner(context.into(), schema_name, table_name, info, props)
637}
638
639fn gen_table_plan_with_source(
640 context: OptimizerContextRef,
641 schema_name: Option<String>,
642 source_catalog: SourceCatalog,
643 version: TableVersion,
644 props: CreateTableProps,
645) -> Result<(PlanRef, TableCatalog)> {
646 let table_name = source_catalog.name.clone();
647
648 let info = CreateTableInfo {
649 columns: source_catalog.columns.clone(),
650 pk_column_ids: source_catalog.pk_col_ids.clone(),
651 row_id_index: source_catalog.row_id_index,
652 watermark_descs: source_catalog.watermark_descs.clone(),
653 source_catalog: Some(source_catalog),
654 version,
655 };
656
657 gen_table_plan_inner(context, schema_name, table_name, info, props)
658}
659
660#[derive(Clone, Copy)]
662pub enum EitherOnConflict {
663 Ast(Option<OnConflict>),
664 Resolved(ConflictBehavior),
665}
666
667impl From<Option<OnConflict>> for EitherOnConflict {
668 fn from(v: Option<OnConflict>) -> Self {
669 Self::Ast(v)
670 }
671}
672
673impl From<ConflictBehavior> for EitherOnConflict {
674 fn from(v: ConflictBehavior) -> Self {
675 Self::Resolved(v)
676 }
677}
678
679impl EitherOnConflict {
680 pub fn to_behavior(self, append_only: bool, row_id_as_pk: bool) -> Result<ConflictBehavior> {
682 let conflict_behavior = match self {
683 EitherOnConflict::Ast(on_conflict) => {
684 if append_only {
685 if row_id_as_pk {
686 ConflictBehavior::NoCheck
688 } else {
689 if let Some(on_conflict) = on_conflict
691 && on_conflict != OnConflict::Nothing
692 {
693 return Err(ErrorCode::InvalidInputSyntax(
694 "When PRIMARY KEY constraint applied to an APPEND ONLY table, \
695 the ON CONFLICT behavior must be DO NOTHING."
696 .to_owned(),
697 )
698 .into());
699 }
700 ConflictBehavior::IgnoreConflict
701 }
702 } else {
703 match on_conflict.unwrap_or(OnConflict::UpdateFull) {
705 OnConflict::UpdateFull => ConflictBehavior::Overwrite,
706 OnConflict::Nothing => ConflictBehavior::IgnoreConflict,
707 OnConflict::UpdateIfNotNull => ConflictBehavior::DoUpdateIfNotNull,
708 }
709 }
710 }
711 EitherOnConflict::Resolved(b) => b,
712 };
713
714 Ok(conflict_behavior)
715 }
716}
717
718pub struct CreateTableInfo {
723 pub columns: Vec<ColumnCatalog>,
724 pub pk_column_ids: Vec<ColumnId>,
725 pub row_id_index: Option<usize>,
726 pub watermark_descs: Vec<WatermarkDesc>,
727 pub source_catalog: Option<SourceCatalog>,
728 pub version: TableVersion,
729}
730
731pub struct CreateTableProps {
736 pub definition: String,
737 pub append_only: bool,
738 pub on_conflict: EitherOnConflict,
739 pub with_version_columns: Vec<String>,
740 pub webhook_info: Option<PbWebhookSourceInfo>,
741 pub engine: Engine,
742}
743
744#[allow(clippy::too_many_arguments)]
745fn gen_table_plan_inner(
746 context: OptimizerContextRef,
747 schema_name: Option<String>,
748 table_name: String,
749 info: CreateTableInfo,
750 props: CreateTableProps,
751) -> Result<(PlanRef, TableCatalog)> {
752 let CreateTableInfo {
753 ref columns,
754 row_id_index,
755 ref watermark_descs,
756 ref source_catalog,
757 ..
758 } = info;
759 let CreateTableProps { append_only, .. } = props;
760
761 let (database_id, schema_id) = context
762 .session_ctx()
763 .get_database_and_schema_id_for_create(schema_name)?;
764
765 let session = context.session_ctx().clone();
766 let retention_seconds = context.with_options().retention_seconds();
767
768 let source_node: LogicalPlanRef = LogicalSource::new(
769 source_catalog.clone().map(Rc::new),
770 columns.clone(),
771 row_id_index,
772 SourceNodeKind::CreateTable,
773 context.clone(),
774 None,
775 )?
776 .into();
777
778 let required_cols = FixedBitSet::with_capacity(columns.len());
779 let plan_root = PlanRoot::new_with_logical_plan(
780 source_node,
781 RequiredDist::Any,
782 Order::any(),
783 required_cols,
784 vec![],
785 );
786
787 let has_non_ttl_watermark = watermark_descs.iter().any(|d| !d.with_ttl);
788
789 if !append_only && has_non_ttl_watermark {
790 return Err(ErrorCode::NotSupported(
791 "Defining watermarks on table requires the table to be append only.".to_owned(),
792 "Use the key words `APPEND ONLY`".to_owned(),
793 )
794 .into());
795 }
796
797 if !append_only && retention_seconds.is_some() {
798 if session
799 .config()
800 .unsafe_enable_storage_retention_for_non_append_only_tables()
801 {
802 tracing::warn!(
803 "Storage retention is enabled for non-append-only table {}. This may lead to stream inconsistency.",
804 table_name
805 );
806 const NOTICE: &str = "Storage retention is enabled for non-append-only table. \
807 This may lead to stream inconsistency and unrecoverable \
808 node failure if there is any row INSERT/UPDATE/DELETE operation \
809 corresponding to the TTLed primary keys";
810 session.notice_to_user(NOTICE);
811 } else {
812 return Err(ErrorCode::NotSupported(
813 "Defining retention seconds on table requires the table to be append only."
814 .to_owned(),
815 "Use the key words `APPEND ONLY`".to_owned(),
816 )
817 .into());
818 }
819 }
820
821 let materialize =
822 plan_root.gen_table_plan(context, table_name, database_id, schema_id, info, props)?;
823
824 let mut table = materialize.table().clone();
825 table.owner = session.user_id();
826
827 Ok((
828 ensure_sync_log_store_fragment_root(materialize.into()),
829 table,
830 ))
831}
832
833#[allow(clippy::too_many_arguments)]
837pub(crate) fn gen_create_table_plan_for_cdc_table(
838 context: OptimizerContextRef,
839 source: Arc<SourceCatalog>,
840 external_table_name: String,
841 column_defs: Vec<ColumnDef>,
842 source_watermarks: Vec<SourceWatermark>,
843 mut columns: Vec<ColumnCatalog>,
844 pk_names: Vec<String>,
845 cdc_with_options: WithOptionsSecResolved,
846 mut col_id_gen: ColumnIdGenerator,
847 on_conflict: Option<OnConflict>,
848 with_version_columns: Vec<String>,
849 include_column_options: IncludeOption,
850 table_name: ObjectName,
851 resolved_table_name: String, database_id: DatabaseId,
853 schema_id: SchemaId,
854 table_id: TableId,
855 engine: Engine,
856) -> Result<(PlanRef, TableCatalog)> {
857 let session = context.session_ctx().clone();
858
859 handle_addition_columns(
861 None,
862 &cdc_with_options,
863 include_column_options,
864 &mut columns,
865 true,
866 )?;
867
868 for c in &mut columns {
869 col_id_gen.generate(c)?;
870 }
871
872 let (mut columns, pk_column_ids, _row_id_index) =
873 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
874
875 let watermark_descs = bind_source_watermark(
876 context.session_ctx(),
877 table_name.real_value(),
878 source_watermarks,
879 &columns,
880 )?;
881
882 bind_sql_column_constraints(
884 context.session_ctx(),
885 table_name.real_value(),
886 &mut columns,
887 &column_defs,
888 &pk_column_ids,
889 )?;
890
891 let definition = context.normalized_sql().to_owned();
892
893 let pk_column_indices = {
894 let mut id_to_idx = HashMap::new();
895 columns.iter().enumerate().for_each(|(idx, c)| {
896 id_to_idx.insert(c.column_id(), idx);
897 });
898 pk_column_ids
900 .iter()
901 .map(|c| id_to_idx.get(c).copied().unwrap())
902 .collect_vec()
903 };
904 let table_pk = pk_column_indices
905 .iter()
906 .map(|idx| ColumnOrder::new(*idx, OrderType::ascending()))
907 .collect();
908
909 let (options, secret_refs) = cdc_with_options.into_parts();
910
911 let non_generated_column_descs = columns
912 .iter()
913 .filter(|&c| !c.is_generated())
914 .map(|c| c.column_desc.clone())
915 .collect_vec();
916 let non_generated_column_num = non_generated_column_descs.len();
917 let cdc_table_type = ExternalCdcTableType::from_properties(&options);
918 let cdc_table_desc = CdcTableDesc {
919 table_id,
920 source_id: source.id, external_table_name: external_table_name.clone(),
922 pk: table_pk,
923 columns: non_generated_column_descs,
924 stream_key: pk_column_indices,
925 connect_properties: options,
926 secret_refs,
927 };
928
929 tracing::debug!(?cdc_table_desc, "create cdc table");
930 let options = build_cdc_scan_options_with_options(context.with_options(), &cdc_table_type)?;
931
932 let logical_scan = LogicalCdcScan::create(
933 external_table_name.clone(),
934 Rc::new(cdc_table_desc),
935 context.clone(),
936 options,
937 );
938
939 let scan_node: LogicalPlanRef = logical_scan.into();
940 let required_cols = FixedBitSet::with_capacity(non_generated_column_num);
941 let plan_root = PlanRoot::new_with_logical_plan(
942 scan_node,
943 RequiredDist::Any,
944 Order::any(),
945 required_cols,
946 vec![],
947 );
948
949 let cdc_table_id_external_table_name = if let ExternalCdcTableType::Postgres = cdc_table_type
950 && let Some(normalized_table_name) =
951 normalize_simple_postgres_quoted_table_name(&external_table_name)
952 {
953 normalized_table_name
954 } else {
955 external_table_name
956 };
957 let cdc_table_id = build_cdc_table_id(source.id, &cdc_table_id_external_table_name);
958 let materialize = plan_root.gen_table_plan(
959 context,
960 resolved_table_name,
961 database_id,
962 schema_id,
963 CreateTableInfo {
964 columns,
965 pk_column_ids,
966 row_id_index: None,
967 watermark_descs,
968 source_catalog: Some((*source).clone()),
969 version: col_id_gen.into_version(),
970 },
971 CreateTableProps {
972 definition,
973 append_only: false,
974 on_conflict: on_conflict.into(),
975 with_version_columns,
976 webhook_info: None,
977 engine,
978 },
979 )?;
980
981 let mut table = materialize.table().clone();
982 table.owner = session.user_id();
983 table.cdc_table_id = Some(cdc_table_id);
984 table.cdc_table_type = Some(cdc_table_type);
985 Ok((
986 ensure_sync_log_store_fragment_root(materialize.into()),
987 table,
988 ))
989}
990
991fn derive_with_options_for_cdc_table(
999 source_with_properties: &WithOptionsSecResolved,
1000 external_table_name: String,
1001) -> Result<(WithOptionsSecResolved, String)> {
1002 use source::cdc::{MYSQL_CDC_CONNECTOR, POSTGRES_CDC_CONNECTOR, SQL_SERVER_CDC_CONNECTOR};
1003 let source_database_name: &str = source_with_properties
1005 .get("database.name")
1006 .ok_or_else(|| anyhow!("The source with properties does not contain 'database.name'"))?
1007 .as_str();
1008 let mut with_options = source_with_properties.clone();
1009 if let Some(connector) = source_with_properties.get(UPSTREAM_SOURCE_KEY) {
1010 match connector.as_str() {
1011 MYSQL_CDC_CONNECTOR => {
1012 let (db_name, table_name) = external_table_name.split_once('.').ok_or_else(|| {
1015 anyhow!("The upstream table name must contain database name prefix, e.g. 'database.table'")
1016 })?;
1017 if !source_database_name
1019 .split(',')
1020 .map(|s| s.trim())
1021 .any(|name| name == db_name)
1022 {
1023 return Err(anyhow!(
1024 "The database name `{}` in the FROM clause is not included in the database name `{}` in source definition",
1025 db_name,
1026 source_database_name
1027 ).into());
1028 }
1029 with_options.insert(DATABASE_NAME_KEY.into(), db_name.into());
1030 with_options.insert(TABLE_NAME_KEY.into(), table_name.into());
1031 return Ok((with_options, external_table_name));
1033 }
1034 POSTGRES_CDC_CONNECTOR => {
1035 let (schema_name, table_name) =
1036 parse_postgres_cdc_external_table_name(&external_table_name)?;
1037
1038 with_options.insert(SCHEMA_NAME_KEY.into(), schema_name);
1040 with_options.insert(TABLE_NAME_KEY.into(), table_name);
1041 return Ok((with_options, external_table_name));
1043 }
1044 SQL_SERVER_CDC_CONNECTOR => {
1045 let parts: Vec<&str> = external_table_name.split('.').collect();
1053 let (schema_name, table_name) = match parts.len() {
1054 3 => {
1055 let db_name = parts[0];
1058 let schema_name = parts[1];
1059 let table_name = parts[2];
1060
1061 if db_name != source_database_name {
1062 return Err(anyhow!(
1063 "The database name '{}' in FROM clause does not match the database name '{}' specified in source definition. \
1064 You can either use 'schema.table' format (recommended) or ensure the database name matches.",
1065 db_name,
1066 source_database_name
1067 ).into());
1068 }
1069 (schema_name, table_name)
1070 }
1071 2 => {
1072 let schema_name = parts[0];
1075 let table_name = parts[1];
1076 (schema_name, table_name)
1077 }
1078 1 => {
1079 return Err(anyhow!(
1082 "Invalid table name format '{}'. For SQL Server CDC, you must specify the schema name. \
1083 Use 'schema.table' format (e.g., 'dbo.{}') or 'database.schema.table' format (e.g., '{}.dbo.{}').",
1084 external_table_name,
1085 external_table_name,
1086 source_database_name,
1087 external_table_name
1088 ).into());
1089 }
1090 _ => {
1091 return Err(anyhow!(
1093 "Invalid table name format '{}'. Expected 'schema.table' or 'database.schema.table'.",
1094 external_table_name
1095 ).into());
1096 }
1097 };
1098
1099 with_options.insert(SCHEMA_NAME_KEY.into(), schema_name.into());
1101 with_options.insert(TABLE_NAME_KEY.into(), table_name.into());
1102
1103 let normalized_external_table_name = format!("{}.{}", schema_name, table_name);
1106 return Ok((with_options, normalized_external_table_name));
1107 }
1108 _ => {
1109 return Err(RwError::from(anyhow!(
1110 "connector {} is not supported for cdc table",
1111 connector
1112 )));
1113 }
1114 };
1115 }
1116 unreachable!("All valid CDC connectors should have returned by now")
1117}
1118
1119fn parse_postgres_cdc_external_table_name(external_table_name: &str) -> Result<(String, String)> {
1124 let mut parts = vec![];
1125 let mut current = String::new();
1126 let mut chars = external_table_name.chars().peekable();
1127 let mut in_quote = false;
1128 let mut just_closed_quote = false;
1129
1130 while let Some(ch) = chars.next() {
1131 if in_quote {
1132 if ch == '"' {
1133 if chars.peek() == Some(&'"') {
1134 current.push('"');
1135 chars.next();
1136 } else {
1137 in_quote = false;
1138 just_closed_quote = true;
1139 }
1140 } else {
1141 current.push(ch);
1142 }
1143 } else {
1144 match ch {
1145 '.' => {
1146 if current.is_empty() {
1147 return Err(anyhow!(
1148 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1149 external_table_name
1150 )
1151 .into());
1152 }
1153 parts.push(std::mem::take(&mut current));
1154 just_closed_quote = false;
1155 }
1156 '"' if current.is_empty() => {
1157 in_quote = true;
1158 }
1159 '"' => {
1160 return Err(anyhow!(
1161 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1162 external_table_name
1163 )
1164 .into());
1165 }
1166 _ if just_closed_quote => {
1167 return Err(anyhow!(
1168 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1169 external_table_name
1170 )
1171 .into());
1172 }
1173 _ => current.push(ch),
1174 }
1175 }
1176 }
1177
1178 if in_quote || current.is_empty() {
1179 return Err(anyhow!(
1180 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1181 external_table_name
1182 )
1183 .into());
1184 }
1185 parts.push(current);
1186
1187 if let [schema_name, table_name] = parts.as_slice() {
1188 Ok((schema_name.clone(), table_name.clone()))
1189 } else {
1190 Err(
1191 anyhow!("The upstream table name must contain schema name prefix, e.g. 'public.table'")
1192 .into(),
1193 )
1194 }
1195}
1196
1197fn reject_pk_filtered_by_debezium_column_filter(
1210 pk_names: &[String],
1211 cdc_with_options: &WithOptionsSecResolved,
1212) -> Result<()> {
1213 const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
1214 const INCLUDE_KEY: &str = "debezium.column.include.list";
1215
1216 let st = SchemaTableName::from_properties(cdc_with_options.as_plaintext());
1217 reject_pk_filtered_by_debezium_column_filter_inner(
1218 pk_names,
1219 &st,
1220 cdc_with_options.get(EXCLUDE_KEY).map(String::as_str),
1221 cdc_with_options.get(INCLUDE_KEY).map(String::as_str),
1222 )
1223}
1224
1225fn reject_pk_filtered_by_debezium_column_filter_inner(
1226 pk_names: &[String],
1227 st: &SchemaTableName,
1228 exclude_list: Option<&str>,
1229 include_list: Option<&str>,
1230) -> Result<()> {
1231 const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
1232 const INCLUDE_KEY: &str = "debezium.column.include.list";
1233
1234 let pk_full_names = pk_names
1235 .iter()
1236 .map(|pk| (pk, format!("{}.{}.{}", st.schema_name, st.table_name, pk)))
1237 .collect_vec();
1238
1239 if let Some(exclude_list) = exclude_list {
1240 let patterns = compile_debezium_column_filter_patterns(EXCLUDE_KEY, exclude_list)?;
1241 for (pk, pk_full_name) in &pk_full_names {
1242 for (pattern, regex) in &patterns {
1243 if regex.is_match(pk_full_name).map_err(|err| {
1244 ErrorCode::InvalidInputSyntax(format!(
1245 "failed to evaluate Debezium column filter pattern `{pattern}` in `{EXCLUDE_KEY}`: {}",
1246 err.as_report()
1247 ))
1248 })? {
1249 return Err(ErrorCode::InvalidInputSyntax(format!(
1250 "primary key column `{pk}` is excluded by `{EXCLUDE_KEY}` pattern \
1251 `{pattern}`. Excluding a PK column causes silent data corruption: \
1252 Debezium keeps the PK in the message key but drops it from the payload, \
1253 so RisingWave cannot match UPDATE/DELETE events against the original row."
1254 ))
1255 .into());
1256 }
1257 }
1258 }
1259 }
1260
1261 if let Some(include_list) = include_list {
1262 let patterns = compile_debezium_column_filter_patterns(INCLUDE_KEY, include_list)?;
1263 for (pk, pk_full_name) in &pk_full_names {
1264 let mut included = false;
1265 for (_, regex) in &patterns {
1266 if regex.is_match(pk_full_name).map_err(|err| {
1267 ErrorCode::InvalidInputSyntax(format!(
1268 "failed to evaluate Debezium column filter pattern in `{INCLUDE_KEY}`: {}",
1269 err.as_report()
1270 ))
1271 })? {
1272 included = true;
1273 break;
1274 }
1275 }
1276 if !included {
1277 return Err(ErrorCode::InvalidInputSyntax(format!(
1278 "primary key column `{pk}` is not included by `{INCLUDE_KEY}`. Omitting a PK \
1279 column causes silent data corruption: Debezium keeps the PK in the message key \
1280 but drops it from the payload, so RisingWave cannot match UPDATE/DELETE events \
1281 against the original row."
1282 ))
1283 .into());
1284 }
1285 }
1286 }
1287
1288 Ok(())
1289}
1290
1291fn compile_debezium_column_filter_patterns(
1292 key: &str,
1293 filter_list: &str,
1294) -> Result<Vec<(String, Regex)>> {
1295 filter_list
1296 .split(',')
1297 .map(str::trim)
1298 .filter(|pattern| !pattern.is_empty())
1299 .map(|pattern| {
1300 let anchored_pattern = format!("(?i:^(?:{pattern})$)");
1301 let regex = Regex::new(&anchored_pattern).map_err(|err| {
1302 ErrorCode::InvalidInputSyntax(format!(
1303 "invalid Debezium column filter pattern `{pattern}` in `{key}`: {}",
1304 err.as_report()
1305 ))
1306 })?;
1307 Ok((pattern.to_owned(), regex))
1308 })
1309 .collect()
1310}
1311
1312#[allow(clippy::too_many_arguments)]
1313pub(super) async fn handle_create_table_plan(
1314 handler_args: HandlerArgs,
1315 explain_options: ExplainOptions,
1316 format_encode: Option<FormatEncodeOptions>,
1317 cdc_table_info: Option<CdcTableInfo>,
1318 table_name: &ObjectName,
1319 column_defs: Vec<ColumnDef>,
1320 wildcard_idx: Option<usize>,
1321 constraints: Vec<TableConstraint>,
1322 source_watermarks: Vec<SourceWatermark>,
1323 append_only: bool,
1324 on_conflict: Option<OnConflict>,
1325 with_version_columns: Vec<String>,
1326 include_column_options: IncludeOption,
1327 webhook_info: Option<WebhookSourceInfo>,
1328 engine: Engine,
1329) -> Result<(
1330 PlanRef,
1331 Option<SourceCatalog>,
1332 TableCatalog,
1333 TableJobType,
1334 Option<SourceId>,
1335)> {
1336 let col_id_gen = ColumnIdGenerator::new_initial();
1337 let format_encode = check_create_table_with_source(
1338 &handler_args.with_options,
1339 format_encode,
1340 &include_column_options,
1341 &cdc_table_info,
1342 )?;
1343 let webhook_info = webhook_info
1344 .map(|info| bind_webhook_info(&handler_args.session, &column_defs, info))
1345 .transpose()?;
1346
1347 let props = CreateTableProps {
1348 definition: handler_args.normalized_sql.clone(),
1349 append_only,
1350 on_conflict: on_conflict.into(),
1351 with_version_columns: with_version_columns.clone(),
1352 webhook_info,
1353 engine,
1354 };
1355
1356 let ((plan, source, table), job_type, shared_shource_id) = match (
1357 format_encode,
1358 cdc_table_info.as_ref(),
1359 ) {
1360 (Some(format_encode), None) => (
1361 gen_create_table_plan_with_source(
1362 handler_args,
1363 explain_options,
1364 table_name.clone(),
1365 column_defs,
1366 wildcard_idx,
1367 constraints,
1368 format_encode,
1369 source_watermarks,
1370 col_id_gen,
1371 include_column_options,
1372 props,
1373 SqlColumnStrategy::FollowChecked,
1374 )
1375 .await?,
1376 TableJobType::General,
1377 None,
1378 ),
1379 (None, None) => {
1380 let context = OptimizerContext::new(handler_args, explain_options);
1381 let (plan, table) = gen_create_table_plan(
1382 context,
1383 table_name.clone(),
1384 column_defs,
1385 constraints,
1386 col_id_gen,
1387 source_watermarks,
1388 props,
1389 false,
1390 )?;
1391
1392 ((plan, None, table), TableJobType::General, None)
1393 }
1394
1395 (None, Some(cdc_table)) => {
1396 sanity_check_for_table_on_cdc_source(
1397 append_only,
1398 &column_defs,
1399 &wildcard_idx,
1400 &constraints,
1401 &source_watermarks,
1402 )?;
1403
1404 generated_columns_check_for_cdc_table(&column_defs)?;
1405 not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
1406
1407 let session = &handler_args.session;
1408 let db_name = &session.database();
1409 let user_name = &session.user_name();
1410 let search_path = session.config().search_path();
1411 let (schema_name, resolved_table_name) =
1412 Binder::resolve_schema_qualified_name(db_name, table_name)?;
1413 let (database_id, schema_id) =
1414 session.get_database_and_schema_id_for_create(schema_name.clone())?;
1415
1416 let (source_schema, source_name) =
1418 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
1419
1420 let source = {
1421 let catalog_reader = session.env().catalog_reader().read_guard();
1422 let schema_path =
1423 SchemaPath::new(source_schema.as_deref(), &search_path, user_name);
1424
1425 let (source, _) = catalog_reader.get_source_by_name(
1426 db_name,
1427 schema_path,
1428 source_name.as_str(),
1429 )?;
1430 source.clone()
1431 };
1432 let (cdc_with_options, normalized_external_table_name) =
1433 derive_with_options_for_cdc_table(
1434 &source.with_properties,
1435 cdc_table.external_table_name.clone(),
1436 )?;
1437
1438 let (columns, pk_names) = match wildcard_idx {
1439 Some(_) => bind_cdc_table_schema_externally(cdc_with_options.clone()).await?,
1440 None => {
1441 for column_def in &column_defs {
1442 for option_def in &column_def.options {
1443 if let ColumnOption::DefaultValue(_)
1444 | ColumnOption::DefaultValueInternal { .. } = option_def.option
1445 {
1446 return Err(ErrorCode::NotSupported(
1447 "Default value for columns defined on the table created from a CDC source".into(),
1448 "Remove the default value expression in the column definitions".into(),
1449 )
1450 .into());
1451 }
1452 }
1453 }
1454
1455 let (columns, pk_names) =
1456 bind_cdc_table_schema(&column_defs, &constraints, false)?;
1457 let (options, secret_refs) = cdc_with_options.clone().into_parts();
1459 let _config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
1460 .context("failed to extract external table config")?;
1461
1462 (columns, pk_names)
1463 }
1464 };
1465
1466 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1471
1472 let context: OptimizerContextRef =
1473 OptimizerContext::new(handler_args, explain_options).into();
1474 let shared_source_id = source.id;
1475 let (plan, table) = gen_create_table_plan_for_cdc_table(
1476 context,
1477 source,
1478 normalized_external_table_name,
1479 column_defs,
1480 source_watermarks,
1481 columns,
1482 pk_names,
1483 cdc_with_options,
1484 col_id_gen,
1485 on_conflict,
1486 with_version_columns,
1487 include_column_options,
1488 table_name.clone(),
1489 resolved_table_name,
1490 database_id,
1491 schema_id,
1492 TableId::placeholder(),
1493 engine,
1494 )?;
1495
1496 (
1497 (plan, None, table),
1498 TableJobType::SharedCdcSource,
1499 Some(shared_source_id),
1500 )
1501 }
1502 (Some(_), Some(_)) => {
1503 return Err(ErrorCode::NotSupported(
1504 "Data format and encoding format doesn't apply to table created from a CDC source"
1505 .into(),
1506 "Remove the FORMAT and ENCODE specification".into(),
1507 )
1508 .into());
1509 }
1510 };
1511 Ok((plan, source, table, job_type, shared_shource_id))
1512}
1513
1514fn generated_columns_check_for_cdc_table(columns: &Vec<ColumnDef>) -> Result<()> {
1516 let mut found_generated_column = false;
1517 for column in columns {
1518 let mut is_generated = false;
1519
1520 for option_def in &column.options {
1521 if let ColumnOption::GeneratedColumns(_) = option_def.option {
1522 is_generated = true;
1523 break;
1524 }
1525 }
1526
1527 if is_generated {
1528 found_generated_column = true;
1529 } else if found_generated_column {
1530 return Err(ErrorCode::NotSupported(
1531 "Non-generated column found after a generated column.".into(),
1532 "Ensure that all generated columns appear at the end of the cdc table definition."
1533 .into(),
1534 )
1535 .into());
1536 }
1537 }
1538 Ok(())
1539}
1540
1541fn not_null_check_for_cdc_table(
1543 wildcard_idx: &Option<usize>,
1544 column_defs: &Vec<ColumnDef>,
1545) -> Result<()> {
1546 if !wildcard_idx.is_some()
1547 && column_defs.iter().any(|col| {
1548 col.options
1549 .iter()
1550 .any(|opt| matches!(opt.option, ColumnOption::NotNull))
1551 })
1552 {
1553 return Err(ErrorCode::NotSupported(
1554 "CDC table with NOT NULL constraint is not supported".to_owned(),
1555 "Please remove the NOT NULL constraint for columns".to_owned(),
1556 )
1557 .into());
1558 }
1559 Ok(())
1560}
1561
1562fn sanity_check_for_table_on_cdc_source(
1564 append_only: bool,
1565 column_defs: &Vec<ColumnDef>,
1566 wildcard_idx: &Option<usize>,
1567 constraints: &Vec<TableConstraint>,
1568 source_watermarks: &Vec<SourceWatermark>,
1569) -> Result<()> {
1570 if wildcard_idx.is_some() && !column_defs.is_empty() {
1572 return Err(ErrorCode::NotSupported(
1573 "wildcard(*) and column definitions cannot be used together".to_owned(),
1574 "Remove the wildcard or column definitions".to_owned(),
1575 )
1576 .into());
1577 }
1578
1579 if !wildcard_idx.is_some()
1581 && !constraints.iter().any(|c| {
1582 matches!(
1583 c,
1584 TableConstraint::Unique {
1585 is_primary: true,
1586 ..
1587 }
1588 )
1589 })
1590 && !column_defs.iter().any(|col| {
1591 col.options
1592 .iter()
1593 .any(|opt| matches!(opt.option, ColumnOption::Unique { is_primary: true }))
1594 })
1595 {
1596 return Err(ErrorCode::NotSupported(
1597 "CDC table without primary key constraint is not supported".to_owned(),
1598 "Please define a primary key".to_owned(),
1599 )
1600 .into());
1601 }
1602
1603 if append_only {
1604 return Err(ErrorCode::NotSupported(
1605 "append only modifier on the table created from a CDC source".into(),
1606 "Remove the APPEND ONLY clause".into(),
1607 )
1608 .into());
1609 }
1610
1611 if !source_watermarks.is_empty()
1612 && source_watermarks
1613 .iter()
1614 .any(|watermark| !watermark.with_ttl)
1615 {
1616 return Err(ErrorCode::NotSupported(
1617 "non-TTL watermark defined on the table created from a CDC source".into(),
1618 "Use `WATERMARK ... WITH TTL` instead.".into(),
1619 )
1620 .into());
1621 }
1622
1623 Ok(())
1624}
1625
1626async fn bind_cdc_table_schema_externally(
1628 cdc_with_options: WithOptionsSecResolved,
1629) -> Result<(Vec<ColumnCatalog>, Vec<String>)> {
1630 let (options, secret_refs) = cdc_with_options.into_parts();
1632 let config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
1633 .context("failed to extract external table config")?;
1634
1635 let table = ExternalTableImpl::connect(config)
1636 .await
1637 .context("failed to auto derive table schema")?;
1638
1639 Ok((
1640 table
1641 .column_descs()
1642 .iter()
1643 .cloned()
1644 .map(|column_desc| ColumnCatalog {
1645 column_desc,
1646 is_hidden: false,
1647 })
1648 .collect(),
1649 table.pk_names().clone(),
1650 ))
1651}
1652
1653fn bind_cdc_table_schema(
1655 column_defs: &Vec<ColumnDef>,
1656 constraints: &Vec<TableConstraint>,
1657 is_for_replace_plan: bool,
1658) -> Result<(Vec<ColumnCatalog>, Vec<String>)> {
1659 let columns = bind_sql_columns(column_defs, is_for_replace_plan)?;
1660
1661 let pk_names = bind_sql_pk_names(column_defs, bind_table_constraints(constraints)?)?;
1662 Ok((columns, pk_names))
1663}
1664
1665#[allow(clippy::too_many_arguments)]
1666pub async fn handle_create_table(
1667 handler_args: HandlerArgs,
1668 table_name: ObjectName,
1669 column_defs: Vec<ColumnDef>,
1670 wildcard_idx: Option<usize>,
1671 constraints: Vec<TableConstraint>,
1672 if_not_exists: bool,
1673 format_encode: Option<FormatEncodeOptions>,
1674 source_watermarks: Vec<SourceWatermark>,
1675 append_only: bool,
1676 on_conflict: Option<OnConflict>,
1677 with_version_columns: Vec<String>,
1678 cdc_table_info: Option<CdcTableInfo>,
1679 include_column_options: IncludeOption,
1680 webhook_info: Option<WebhookSourceInfo>,
1681 ast_engine: risingwave_sqlparser::ast::Engine,
1682) -> Result<RwPgResponse> {
1683 let session = handler_args.session.clone();
1684
1685 if append_only {
1686 session.notice_to_user("APPEND ONLY TABLE is currently an experimental feature.");
1687 }
1688
1689 session.check_cluster_limits().await?;
1690
1691 let engine = match ast_engine {
1692 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1693 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1694 };
1695
1696 if let Either::Right(resp) = session.check_relation_name_duplicated(
1697 table_name.clone(),
1698 StatementType::CREATE_TABLE,
1699 if_not_exists,
1700 )? {
1701 return Ok(resp);
1702 }
1703
1704 let (graph, source, hummock_table, job_type, shared_source_id) = {
1705 let (plan, source, table, job_type, shared_source_id) = handle_create_table_plan(
1706 handler_args.clone(),
1707 ExplainOptions::default(),
1708 format_encode,
1709 cdc_table_info,
1710 &table_name,
1711 column_defs.clone(),
1712 wildcard_idx,
1713 constraints.clone(),
1714 source_watermarks,
1715 append_only,
1716 on_conflict,
1717 with_version_columns,
1718 include_column_options,
1719 webhook_info,
1720 engine,
1721 )
1722 .await?;
1723 tracing::trace!("table_plan: {:?}", plan.explain_to_string());
1724
1725 let graph = build_graph(plan, Some(GraphJobType::Table))?;
1726
1727 (graph, source, table, job_type, shared_source_id)
1728 };
1729
1730 tracing::trace!(
1731 "name={}, graph=\n{}",
1732 table_name,
1733 serde_json::to_string_pretty(&graph).unwrap()
1734 );
1735
1736 let dependencies = shared_source_id
1737 .map(|id| HashSet::from([id.as_object_id()]))
1738 .unwrap_or_default();
1739
1740 match engine {
1742 Engine::Hummock => {
1743 let catalog_writer = session.catalog_writer()?;
1744 let action = match job_type {
1745 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1746 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1747 };
1748 execute_with_long_running_notification(
1749 catalog_writer.create_table(
1750 source.map(|s| s.to_prost()),
1751 hummock_table.to_prost(),
1752 graph,
1753 job_type,
1754 if_not_exists,
1755 dependencies,
1756 ),
1757 &session,
1758 "CREATE TABLE",
1759 action,
1760 )
1761 .await?;
1762 }
1763 Engine::Iceberg => {
1764 let hummock_table_name = hummock_table.name.clone();
1765 session.create_staging_table(hummock_table.clone());
1766 let res = Box::pin(create_iceberg_engine_table(
1767 session.clone(),
1768 handler_args,
1769 source.map(|s| s.to_prost()),
1770 hummock_table,
1771 graph,
1772 table_name,
1773 job_type,
1774 if_not_exists,
1775 ))
1776 .await;
1777 session.drop_staging_table(&hummock_table_name);
1778 res?
1779 }
1780 }
1781
1782 Ok(PgResponse::empty_result(StatementType::CREATE_TABLE))
1783}
1784
1785#[allow(clippy::too_many_arguments)]
1794pub async fn create_iceberg_engine_table(
1795 session: Arc<SessionImpl>,
1796 handler_args: HandlerArgs,
1797 mut source: Option<PbSource>,
1798 table: TableCatalog,
1799 graph: StreamFragmentGraph,
1800 table_name: ObjectName,
1801 job_type: PbTableJobType,
1802 if_not_exists: bool,
1803) -> Result<()> {
1804 let rw_db_name = session
1805 .env()
1806 .catalog_reader()
1807 .read_guard()
1808 .get_database_by_id(table.database_id)?
1809 .name()
1810 .to_owned();
1811 let rw_schema_name = session
1812 .env()
1813 .catalog_reader()
1814 .read_guard()
1815 .get_schema_by_id(table.database_id, table.schema_id)?
1816 .name()
1817 .clone();
1818 let iceberg_catalog_name = rw_db_name.clone();
1819 let iceberg_database_name = rw_schema_name.clone();
1820 let iceberg_table_name = table_name.0.last().unwrap().real_value();
1821
1822 let iceberg_engine_connection: String = session.config().iceberg_engine_connection();
1823 let sink_decouple = session.config().sink_decouple();
1824 if matches!(sink_decouple, SinkDecouple::Disable) {
1825 bail!(
1826 "Iceberg engine table only supports with sink decouple, try `set sink_decouple = true` to resolve it"
1827 );
1828 }
1829
1830 let mut connection_ref = BTreeMap::new();
1831 let with_common = if iceberg_engine_connection.is_empty() {
1832 bail!("to use iceberg engine table, the variable `iceberg_engine_connection` must be set.");
1833 } else {
1834 let parts: Vec<&str> = iceberg_engine_connection.split('.').collect();
1835 assert_eq!(parts.len(), 2);
1836 let connection_catalog =
1837 session.get_connection_by_name(Some(parts[0].to_owned()), parts[1])?;
1838 if let ConnectionInfo::ConnectionParams(params) = &connection_catalog.info {
1839 if params.connection_type == ConnectionType::Iceberg as i32 {
1840 connection_ref.insert(
1842 "connection".to_owned(),
1843 ConnectionRefValue {
1844 connection_name: ObjectName::from(vec![
1845 Ident::from(parts[0]),
1846 Ident::from(parts[1]),
1847 ]),
1848 },
1849 );
1850
1851 let mut with_common = BTreeMap::new();
1852 with_common.insert("connector".to_owned(), "iceberg".to_owned());
1853 with_common.insert("database.name".to_owned(), iceberg_database_name);
1854 with_common.insert("table.name".to_owned(), iceberg_table_name);
1855
1856 let hosted_catalog = params
1857 .properties
1858 .get("hosted_catalog")
1859 .map(|s| s.eq_ignore_ascii_case("true"))
1860 .unwrap_or(false);
1861 if hosted_catalog {
1862 let meta_client = session.env().meta_client();
1863 let meta_store_endpoint = meta_client.get_meta_store_endpoint().await?;
1864
1865 let meta_store_endpoint =
1866 url::Url::parse(&meta_store_endpoint).map_err(|_| {
1867 ErrorCode::InternalError(
1868 "failed to parse the meta store endpoint".to_owned(),
1869 )
1870 })?;
1871 let meta_store_backend = meta_store_endpoint.scheme().to_owned();
1872 let meta_store_user = meta_store_endpoint.username().to_owned();
1873 let meta_store_password = match meta_store_endpoint.password() {
1874 Some(password) => percent_decode_str(password)
1875 .decode_utf8()
1876 .map_err(|_| {
1877 ErrorCode::InternalError(
1878 "failed to parse password from meta store endpoint".to_owned(),
1879 )
1880 })?
1881 .into_owned(),
1882 None => "".to_owned(),
1883 };
1884 let meta_store_host = meta_store_endpoint
1885 .host_str()
1886 .ok_or_else(|| {
1887 ErrorCode::InternalError(
1888 "failed to parse host from meta store endpoint".to_owned(),
1889 )
1890 })?
1891 .to_owned();
1892 let meta_store_port = meta_store_endpoint.port().ok_or_else(|| {
1893 ErrorCode::InternalError(
1894 "failed to parse port from meta store endpoint".to_owned(),
1895 )
1896 })?;
1897 let meta_store_database = meta_store_endpoint
1898 .path()
1899 .trim_start_matches('/')
1900 .to_owned();
1901
1902 let Ok(meta_backend) = MetaBackend::from_str(&meta_store_backend, true) else {
1903 bail!("failed to parse meta backend: {}", meta_store_backend);
1904 };
1905
1906 let catalog_uri = match meta_backend {
1907 MetaBackend::Postgres => {
1908 format!(
1909 "jdbc:postgresql://{}:{}/{}",
1910 meta_store_host, meta_store_port, meta_store_database
1911 )
1912 }
1913 MetaBackend::Mysql => {
1914 format!(
1915 "jdbc:mysql://{}:{}/{}",
1916 meta_store_host, meta_store_port, meta_store_database
1917 )
1918 }
1919 MetaBackend::Sqlite | MetaBackend::Sql | MetaBackend::Mem => {
1920 bail!(
1921 "Unsupported meta backend for iceberg engine table: {}",
1922 meta_store_backend
1923 );
1924 }
1925 };
1926
1927 with_common.insert("catalog.type".to_owned(), "jdbc".to_owned());
1928 with_common.insert("catalog.uri".to_owned(), catalog_uri);
1929 with_common.insert("catalog.jdbc.user".to_owned(), meta_store_user);
1930 with_common.insert("catalog.jdbc.password".to_owned(), meta_store_password);
1931 with_common.insert("catalog.name".to_owned(), iceberg_catalog_name);
1932 }
1933
1934 with_common
1935 } else {
1936 return Err(RwError::from(ErrorCode::InvalidParameterValue(
1937 "Only iceberg connection could be used in iceberg engine".to_owned(),
1938 )));
1939 }
1940 } else {
1941 return Err(RwError::from(ErrorCode::InvalidParameterValue(
1942 "Private Link Service has been deprecated. Please create a new connection instead."
1943 .to_owned(),
1944 )));
1945 }
1946 };
1947
1948 let mut pks = table
1951 .pk_column_names()
1952 .iter()
1953 .map(|c| c.to_string())
1954 .collect::<Vec<String>>();
1955
1956 if pks.len() == 1 && pks[0].eq(ROW_ID_COLUMN_NAME) {
1958 pks = vec![RISINGWAVE_ICEBERG_ROW_ID.to_owned()];
1959 }
1960
1961 let sink_from = CreateSink::From(table_name.clone());
1962
1963 let mut sink_name = table_name.clone();
1964 *sink_name.0.last_mut().unwrap() = Ident::from(
1965 (ICEBERG_SINK_PREFIX.to_owned() + &sink_name.0.last().unwrap().real_value()).as_str(),
1966 );
1967 let create_sink_stmt = CreateSinkStatement {
1968 or_replace: false,
1969 if_not_exists: false,
1970 sink_name,
1971 with_properties: WithProperties(vec![]),
1972 sink_from,
1973 columns: vec![],
1974 emit_mode: None,
1975 sink_schema: None,
1976 into_table_name: None,
1977 };
1978
1979 let mut sink_handler_args = handler_args.clone();
1980
1981 let mut sink_with = with_common.clone();
1982
1983 let enable_pk_index = handler_args
1984 .with_options
1985 .get(ENABLE_PK_INDEX)
1986 .is_some_and(|val| val.eq_ignore_ascii_case("true"));
1987
1988 if !enable_pk_index {
1990 sink_with.insert(AUTO_SCHEMA_CHANGE_KEY.to_owned(), "true".to_owned());
1991 }
1992
1993 if table.append_only {
1994 sink_with.insert("type".to_owned(), "append-only".to_owned());
1995 } else {
1996 sink_with.insert("type".to_owned(), "upsert".to_owned());
1997 if !enable_pk_index {
1998 sink_with.insert("primary_key".to_owned(), pks.join(","));
1999 }
2000 }
2001 let commit_checkpoint_interval = handler_args
2020 .with_options
2021 .get(COMMIT_CHECKPOINT_INTERVAL)
2022 .map(|v| v.to_owned())
2023 .unwrap_or_else(|| "60".to_owned());
2024 let commit_checkpoint_interval = commit_checkpoint_interval.parse::<u32>().map_err(|_| {
2025 ErrorCode::InvalidInputSyntax(format!(
2026 "commit_checkpoint_interval must be greater than 0: {}",
2027 commit_checkpoint_interval
2028 ))
2029 })?;
2030
2031 if commit_checkpoint_interval == 0 {
2032 bail!("commit_checkpoint_interval must be greater than 0");
2033 }
2034
2035 source
2037 .as_mut()
2038 .map(|x| x.with_properties.remove(COMMIT_CHECKPOINT_INTERVAL));
2039
2040 let sink_decouple = session.config().sink_decouple();
2041 if matches!(sink_decouple, SinkDecouple::Disable) && commit_checkpoint_interval > 1 {
2042 bail!(
2043 "config conflict: `commit_checkpoint_interval` larger than 1 means that sink decouple must be enabled, but session config sink_decouple is disabled"
2044 )
2045 }
2046
2047 sink_with.insert(
2048 COMMIT_CHECKPOINT_INTERVAL.to_owned(),
2049 commit_checkpoint_interval.to_string(),
2050 );
2051 sink_with.insert("create_table_if_not_exists".to_owned(), "true".to_owned());
2052
2053 sink_with.insert("is_exactly_once".to_owned(), "true".to_owned());
2054
2055 if let Some(enable_compaction) = handler_args.with_options.get(ENABLE_COMPACTION) {
2056 match enable_compaction.to_lowercase().as_str() {
2057 "true" => {
2058 sink_with.insert(ENABLE_COMPACTION.to_owned(), "true".to_owned());
2059 }
2060 "false" => {
2061 sink_with.insert(ENABLE_COMPACTION.to_owned(), "false".to_owned());
2062 }
2063 _ => {
2064 return Err(ErrorCode::InvalidInputSyntax(format!(
2065 "enable_compaction must be true or false: {}",
2066 enable_compaction
2067 ))
2068 .into());
2069 }
2070 }
2071
2072 source
2074 .as_mut()
2075 .map(|x| x.with_properties.remove("enable_compaction"));
2076 } else {
2077 sink_with.insert(ENABLE_COMPACTION.to_owned(), "true".to_owned());
2078 }
2079
2080 if let Some(compaction_interval_sec) = handler_args.with_options.get(COMPACTION_INTERVAL_SEC) {
2081 let compaction_interval_sec = compaction_interval_sec.parse::<u64>().map_err(|_| {
2082 ErrorCode::InvalidInputSyntax(format!(
2083 "compaction_interval_sec must be greater than 0: {}",
2084 commit_checkpoint_interval
2085 ))
2086 })?;
2087 if compaction_interval_sec == 0 {
2088 bail!("compaction_interval_sec must be greater than 0");
2089 }
2090 sink_with.insert(
2091 "compaction_interval_sec".to_owned(),
2092 compaction_interval_sec.to_string(),
2093 );
2094 source
2096 .as_mut()
2097 .map(|x| x.with_properties.remove("compaction_interval_sec"));
2098 }
2099
2100 let has_enabled_snapshot_expiration = if let Some(enable_snapshot_expiration) =
2101 handler_args.with_options.get(ENABLE_SNAPSHOT_EXPIRATION)
2102 {
2103 source
2105 .as_mut()
2106 .map(|x| x.with_properties.remove(ENABLE_SNAPSHOT_EXPIRATION));
2107 match enable_snapshot_expiration.to_lowercase().as_str() {
2108 "true" => {
2109 sink_with.insert(ENABLE_SNAPSHOT_EXPIRATION.to_owned(), "true".to_owned());
2110 true
2111 }
2112 "false" => {
2113 sink_with.insert(ENABLE_SNAPSHOT_EXPIRATION.to_owned(), "false".to_owned());
2114 false
2115 }
2116 _ => {
2117 return Err(ErrorCode::InvalidInputSyntax(format!(
2118 "enable_snapshot_expiration must be true or false: {}",
2119 enable_snapshot_expiration
2120 ))
2121 .into());
2122 }
2123 }
2124 } else {
2125 sink_with.insert(ENABLE_SNAPSHOT_EXPIRATION.to_owned(), "true".to_owned());
2126 true
2127 };
2128
2129 if has_enabled_snapshot_expiration {
2130 if let Some(snapshot_expiration_retain_last) = handler_args
2132 .with_options
2133 .get(SNAPSHOT_EXPIRATION_RETAIN_LAST)
2134 {
2135 sink_with.insert(
2136 SNAPSHOT_EXPIRATION_RETAIN_LAST.to_owned(),
2137 snapshot_expiration_retain_last.to_owned(),
2138 );
2139 source
2141 .as_mut()
2142 .map(|x| x.with_properties.remove(SNAPSHOT_EXPIRATION_RETAIN_LAST));
2143 }
2144
2145 if let Some(snapshot_expiration_max_age) = handler_args
2146 .with_options
2147 .get(SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS)
2148 {
2149 sink_with.insert(
2150 SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS.to_owned(),
2151 snapshot_expiration_max_age.to_owned(),
2152 );
2153 source
2155 .as_mut()
2156 .map(|x| x.with_properties.remove(SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS));
2157 }
2158
2159 if let Some(snapshot_expiration_clear_expired_files) = handler_args
2160 .with_options
2161 .get(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES)
2162 {
2163 sink_with.insert(
2164 SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES.to_owned(),
2165 snapshot_expiration_clear_expired_files.to_owned(),
2166 );
2167 source.as_mut().map(|x| {
2169 x.with_properties
2170 .remove(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES)
2171 });
2172 }
2173
2174 if let Some(snapshot_expiration_clear_expired_meta_data) = handler_args
2175 .with_options
2176 .get(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA)
2177 {
2178 sink_with.insert(
2179 SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA.to_owned(),
2180 snapshot_expiration_clear_expired_meta_data.to_owned(),
2181 );
2182 source.as_mut().map(|x| {
2184 x.with_properties
2185 .remove(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA)
2186 });
2187 }
2188 }
2189
2190 if let Some(format_version) = handler_args.with_options.get(FORMAT_VERSION) {
2191 let format_version = format_version.parse::<u8>().map_err(|_| {
2192 ErrorCode::InvalidInputSyntax(format!(
2193 "format_version must be 1, 2 or 3: {}",
2194 format_version
2195 ))
2196 })?;
2197 if format_version != 1 && format_version != 2 && format_version != 3 {
2198 bail!("format_version must be 1, 2 or 3");
2199 }
2200 sink_with.insert(FORMAT_VERSION.to_owned(), format_version.to_string());
2201
2202 source
2204 .as_mut()
2205 .map(|x| x.with_properties.remove(FORMAT_VERSION));
2206 }
2207
2208 if let Some(write_mode) = handler_args.with_options.get(WRITE_MODE) {
2209 let write_mode = IcebergWriteMode::try_from(write_mode.as_str()).map_err(|_| {
2210 ErrorCode::InvalidInputSyntax(format!(
2211 "invalid write_mode: {}, must be one of: {}, {}",
2212 write_mode, ICEBERG_WRITE_MODE_MERGE_ON_READ, ICEBERG_WRITE_MODE_COPY_ON_WRITE
2213 ))
2214 })?;
2215
2216 match write_mode {
2217 IcebergWriteMode::MergeOnRead => {
2218 sink_with.insert(WRITE_MODE.to_owned(), write_mode.as_str().to_owned());
2219 }
2220
2221 IcebergWriteMode::CopyOnWrite => {
2222 if table.append_only {
2223 return Err(ErrorCode::NotSupported(
2224 "COPY ON WRITE is not supported for append-only iceberg table".to_owned(),
2225 "Please use MERGE ON READ instead".to_owned(),
2226 )
2227 .into());
2228 }
2229
2230 sink_with.insert(WRITE_MODE.to_owned(), write_mode.as_str().to_owned());
2231 }
2232 }
2233
2234 source
2236 .as_mut()
2237 .map(|x| x.with_properties.remove("write_mode"));
2238 } else {
2239 sink_with.insert(
2240 WRITE_MODE.to_owned(),
2241 ICEBERG_WRITE_MODE_MERGE_ON_READ.to_owned(),
2242 );
2243 }
2244
2245 if let Some(enable_pk_index) = handler_args.with_options.get(ENABLE_PK_INDEX) {
2246 match enable_pk_index.to_lowercase().as_str() {
2247 "true" => {
2248 sink_with.insert(ENABLE_PK_INDEX.to_owned(), "true".to_owned());
2249 }
2250 "false" => {
2251 sink_with.insert(ENABLE_PK_INDEX.to_owned(), "false".to_owned());
2252 }
2253 _ => {
2254 return Err(ErrorCode::InvalidInputSyntax(format!(
2255 "enable_pk_index must be true or false: {}",
2256 enable_pk_index
2257 ))
2258 .into());
2259 }
2260 }
2261
2262 source
2264 .as_mut()
2265 .map(|x| x.with_properties.remove(ENABLE_PK_INDEX));
2266 }
2267
2268 if let Some(max_snapshots_num_before_compaction) =
2269 handler_args.with_options.get(COMPACTION_MAX_SNAPSHOTS_NUM)
2270 {
2271 let max_snapshots_num_before_compaction = max_snapshots_num_before_compaction
2272 .parse::<u32>()
2273 .map_err(|_| {
2274 ErrorCode::InvalidInputSyntax(format!(
2275 "{} must be greater than 0: {}",
2276 COMPACTION_MAX_SNAPSHOTS_NUM, max_snapshots_num_before_compaction
2277 ))
2278 })?;
2279
2280 if max_snapshots_num_before_compaction == 0 {
2281 bail!(format!(
2282 "{} must be greater than 0",
2283 COMPACTION_MAX_SNAPSHOTS_NUM
2284 ));
2285 }
2286
2287 sink_with.insert(
2288 COMPACTION_MAX_SNAPSHOTS_NUM.to_owned(),
2289 max_snapshots_num_before_compaction.to_string(),
2290 );
2291
2292 source
2294 .as_mut()
2295 .map(|x| x.with_properties.remove(COMPACTION_MAX_SNAPSHOTS_NUM));
2296 }
2297
2298 if let Some(small_files_threshold_mb) = handler_args
2299 .with_options
2300 .get(COMPACTION_SMALL_FILES_THRESHOLD_MB)
2301 {
2302 let small_files_threshold_mb = small_files_threshold_mb.parse::<u64>().map_err(|_| {
2303 ErrorCode::InvalidInputSyntax(format!(
2304 "{} must be greater than 0: {}",
2305 COMPACTION_SMALL_FILES_THRESHOLD_MB, small_files_threshold_mb
2306 ))
2307 })?;
2308 if small_files_threshold_mb == 0 {
2309 bail!(format!(
2310 "{} must be a greater than 0",
2311 COMPACTION_SMALL_FILES_THRESHOLD_MB
2312 ));
2313 }
2314 sink_with.insert(
2315 COMPACTION_SMALL_FILES_THRESHOLD_MB.to_owned(),
2316 small_files_threshold_mb.to_string(),
2317 );
2318
2319 source.as_mut().map(|x| {
2321 x.with_properties
2322 .remove(COMPACTION_SMALL_FILES_THRESHOLD_MB)
2323 });
2324 }
2325
2326 if let Some(delete_files_count_threshold) = handler_args
2327 .with_options
2328 .get(COMPACTION_DELETE_FILES_COUNT_THRESHOLD)
2329 {
2330 let delete_files_count_threshold =
2331 delete_files_count_threshold.parse::<usize>().map_err(|_| {
2332 ErrorCode::InvalidInputSyntax(format!(
2333 "{} must be greater than 0: {}",
2334 COMPACTION_DELETE_FILES_COUNT_THRESHOLD, delete_files_count_threshold
2335 ))
2336 })?;
2337 if delete_files_count_threshold == 0 {
2338 bail!(format!(
2339 "{} must be greater than 0",
2340 COMPACTION_DELETE_FILES_COUNT_THRESHOLD
2341 ));
2342 }
2343 sink_with.insert(
2344 COMPACTION_DELETE_FILES_COUNT_THRESHOLD.to_owned(),
2345 delete_files_count_threshold.to_string(),
2346 );
2347
2348 source.as_mut().map(|x| {
2350 x.with_properties
2351 .remove(COMPACTION_DELETE_FILES_COUNT_THRESHOLD)
2352 });
2353 }
2354
2355 if let Some(trigger_snapshot_count) = handler_args
2356 .with_options
2357 .get(COMPACTION_TRIGGER_SNAPSHOT_COUNT)
2358 {
2359 let trigger_snapshot_count = trigger_snapshot_count.parse::<usize>().map_err(|_| {
2360 ErrorCode::InvalidInputSyntax(format!(
2361 "{} must be greater than 0: {}",
2362 COMPACTION_TRIGGER_SNAPSHOT_COUNT, trigger_snapshot_count
2363 ))
2364 })?;
2365 if trigger_snapshot_count == 0 {
2366 bail!(format!(
2367 "{} must be greater than 0",
2368 COMPACTION_TRIGGER_SNAPSHOT_COUNT
2369 ));
2370 }
2371 sink_with.insert(
2372 COMPACTION_TRIGGER_SNAPSHOT_COUNT.to_owned(),
2373 trigger_snapshot_count.to_string(),
2374 );
2375
2376 source
2378 .as_mut()
2379 .map(|x| x.with_properties.remove(COMPACTION_TRIGGER_SNAPSHOT_COUNT));
2380 }
2381
2382 if let Some(target_file_size_mb) = handler_args
2383 .with_options
2384 .get(COMPACTION_TARGET_FILE_SIZE_MB)
2385 {
2386 let target_file_size_mb = target_file_size_mb.parse::<u64>().map_err(|_| {
2387 ErrorCode::InvalidInputSyntax(format!(
2388 "{} must be greater than 0: {}",
2389 COMPACTION_TARGET_FILE_SIZE_MB, target_file_size_mb
2390 ))
2391 })?;
2392 if target_file_size_mb == 0 {
2393 bail!(format!(
2394 "{} must be greater than 0",
2395 COMPACTION_TARGET_FILE_SIZE_MB
2396 ));
2397 }
2398 sink_with.insert(
2399 COMPACTION_TARGET_FILE_SIZE_MB.to_owned(),
2400 target_file_size_mb.to_string(),
2401 );
2402 source
2404 .as_mut()
2405 .map(|x| x.with_properties.remove(COMPACTION_TARGET_FILE_SIZE_MB));
2406 }
2407
2408 if let Some(compaction_type) = handler_args.with_options.get(COMPACTION_TYPE) {
2409 let compaction_type = CompactionType::try_from(compaction_type.as_str()).map_err(|_| {
2410 ErrorCode::InvalidInputSyntax(format!(
2411 "invalid compaction_type: {}, must be one of {:?}",
2412 compaction_type,
2413 [
2414 CompactionType::Full,
2415 CompactionType::SmallFiles,
2416 CompactionType::FilesWithDelete
2417 ]
2418 ))
2419 })?;
2420
2421 sink_with.insert(
2422 COMPACTION_TYPE.to_owned(),
2423 compaction_type.as_str().to_owned(),
2424 );
2425
2426 source
2428 .as_mut()
2429 .map(|x| x.with_properties.remove(COMPACTION_TYPE));
2430 }
2431
2432 if let Some(write_parquet_compression) = handler_args
2433 .with_options
2434 .get(COMPACTION_WRITE_PARQUET_COMPRESSION)
2435 {
2436 sink_with.insert(
2437 COMPACTION_WRITE_PARQUET_COMPRESSION.to_owned(),
2438 write_parquet_compression.to_owned(),
2439 );
2440 source.as_mut().map(|x| {
2442 x.with_properties
2443 .remove(COMPACTION_WRITE_PARQUET_COMPRESSION)
2444 });
2445 }
2446
2447 if let Some(write_parquet_max_row_group_rows) = handler_args
2448 .with_options
2449 .get(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS)
2450 {
2451 let write_parquet_max_row_group_rows = write_parquet_max_row_group_rows
2452 .parse::<usize>()
2453 .map_err(|_| {
2454 ErrorCode::InvalidInputSyntax(format!(
2455 "{} must be a positive integer: {}",
2456 COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS, write_parquet_max_row_group_rows
2457 ))
2458 })?;
2459 if write_parquet_max_row_group_rows == 0 {
2460 bail!(format!(
2461 "{} must be greater than 0",
2462 COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS
2463 ));
2464 }
2465 sink_with.insert(
2466 COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS.to_owned(),
2467 write_parquet_max_row_group_rows.to_string(),
2468 );
2469 source.as_mut().map(|x| {
2471 x.with_properties
2472 .remove(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS)
2473 });
2474 }
2475
2476 if let Some(write_parquet_max_row_group_bytes) = handler_args
2477 .with_options
2478 .get(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES)
2479 {
2480 let write_parquet_max_row_group_bytes = write_parquet_max_row_group_bytes
2481 .parse::<usize>()
2482 .map_err(|_| {
2483 ErrorCode::InvalidInputSyntax(format!(
2484 "{} must be a positive integer: {}",
2485 COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES, write_parquet_max_row_group_bytes
2486 ))
2487 })?;
2488 if write_parquet_max_row_group_bytes == 0 {
2489 bail!(format!(
2490 "{} must be greater than 0",
2491 COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES
2492 ));
2493 }
2494 sink_with.insert(
2495 COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES.to_owned(),
2496 write_parquet_max_row_group_bytes.to_string(),
2497 );
2498 source.as_mut().map(|x| {
2499 x.with_properties
2500 .remove(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES)
2501 });
2502 }
2503
2504 let partition_by = handler_args
2505 .with_options
2506 .get("partition_by")
2507 .map(|v| v.to_owned());
2508
2509 if let Some(partition_by) = &partition_by {
2510 let mut partition_columns = vec![];
2511 for (column, _) in parse_partition_by_exprs(partition_by.clone())? {
2512 table
2513 .columns()
2514 .iter()
2515 .find(|col| col.name().eq_ignore_ascii_case(&column))
2516 .ok_or_else(|| {
2517 ErrorCode::InvalidInputSyntax(format!(
2518 "Partition source column does not exist in schema: {}",
2519 column
2520 ))
2521 })?;
2522
2523 partition_columns.push(column);
2524 }
2525
2526 ensure_partition_columns_are_prefix_of_primary_key(&partition_columns, &pks).map_err(
2527 |_| {
2528 ErrorCode::InvalidInputSyntax(
2529 "The partition columns should be the prefix of the primary key".to_owned(),
2530 )
2531 },
2532 )?;
2533
2534 sink_with.insert("partition_by".to_owned(), partition_by.to_owned());
2535
2536 source
2538 .as_mut()
2539 .map(|x| x.with_properties.remove("partition_by"));
2540 }
2541
2542 let order_key = handler_args
2543 .with_options
2544 .get(ORDER_KEY)
2545 .map(|v| v.to_owned());
2546 if let Some(order_key) = &order_key {
2547 validate_order_key_columns(order_key, table.columns().iter().map(|col| col.name()))
2548 .map_err(|err| ErrorCode::InvalidInputSyntax(err.to_report_string()))?;
2549
2550 sink_with.insert(ORDER_KEY.to_owned(), order_key.to_owned());
2551
2552 source.as_mut().map(|x| x.with_properties.remove(ORDER_KEY));
2553 }
2554
2555 sink_handler_args.with_options =
2556 WithOptions::new(sink_with, Default::default(), connection_ref.clone());
2557 let SinkPlanContext {
2558 sink_plan,
2559 sink_catalog,
2560 ..
2561 } = gen_sink_plan(sink_handler_args, create_sink_stmt, None, true).await?;
2562 let sink_graph = build_graph(sink_plan, Some(GraphJobType::Sink))?;
2563
2564 let mut source_name = table_name.clone();
2565 *source_name.0.last_mut().unwrap() = Ident::from(
2566 (ICEBERG_SOURCE_PREFIX.to_owned() + &source_name.0.last().unwrap().real_value()).as_str(),
2567 );
2568 let create_source_stmt = CreateSourceStatement {
2569 temporary: false,
2570 if_not_exists: false,
2571 columns: vec![],
2572 source_name,
2573 wildcard_idx: Some(0),
2574 constraints: vec![],
2575 with_properties: WithProperties(vec![]),
2576 format_encode: CompatibleFormatEncode::V2(FormatEncodeOptions::none()),
2577 source_watermarks: vec![],
2578 include_column_options: vec![],
2579 };
2580
2581 let mut source_handler_args = handler_args.clone();
2582 let source_with = with_common;
2583 source_handler_args.with_options =
2584 WithOptions::new(source_with, Default::default(), connection_ref);
2585
2586 let overwrite_options = OverwriteOptions::new(&mut source_handler_args);
2587 let format_encode = create_source_stmt.format_encode.into_v2_with_warning();
2588 let (with_properties, refresh_mode) =
2589 bind_connector_props(&source_handler_args, &format_encode, true)?;
2590
2591 let (iceberg_catalog, table_identifier) = {
2594 let sink_param = SinkParam::try_from_sink_catalog(sink_catalog.clone())?;
2595 let iceberg_sink = IcebergSink::try_from(sink_param)?;
2596 iceberg_sink.create_table_if_not_exists().await?;
2597
2598 let iceberg_catalog = iceberg_sink.config.create_catalog().await?;
2599 let table_identifier = iceberg_sink.config.full_table_name()?;
2600 (iceberg_catalog, table_identifier)
2601 };
2602
2603 let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
2604 let (columns_from_resolve_source, source_info) = bind_columns_from_source(
2605 &session,
2606 &format_encode,
2607 Either::Left(&with_properties),
2608 create_source_type,
2609 )
2610 .await?;
2611 let mut col_id_gen = ColumnIdGenerator::new_initial();
2612
2613 let iceberg_source_catalog = bind_create_source_or_table_with_connector(
2614 source_handler_args,
2615 create_source_stmt.source_name,
2616 format_encode,
2617 with_properties,
2618 &create_source_stmt.columns,
2619 create_source_stmt.constraints,
2620 create_source_stmt.wildcard_idx,
2621 create_source_stmt.source_watermarks,
2622 columns_from_resolve_source,
2623 source_info,
2624 create_source_stmt.include_column_options,
2625 &mut col_id_gen,
2626 create_source_type,
2627 overwrite_options.source_rate_limit,
2628 SqlColumnStrategy::FollowChecked,
2629 refresh_mode,
2630 )
2631 .await?;
2632
2633 let _ = Jvm::get_or_init()?;
2636
2637 let catalog_writer = session.catalog_writer()?;
2638 let action = match job_type {
2639 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
2640 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
2641 };
2642 let res = execute_with_long_running_notification(
2643 catalog_writer.create_iceberg_table(
2644 PbTableJobInfo {
2645 source,
2646 table: Some(table.to_prost()),
2647 fragment_graph: Some(graph),
2648 job_type: job_type as _,
2649 },
2650 PbSinkJobInfo {
2651 sink: Some(sink_catalog.to_proto()),
2652 fragment_graph: Some(sink_graph),
2653 },
2654 iceberg_source_catalog.to_prost(),
2655 if_not_exists,
2656 ),
2657 &session,
2658 "CREATE TABLE",
2659 action,
2660 )
2661 .await;
2662
2663 if res.is_err() {
2664 let _ = iceberg_catalog
2665 .drop_table(&table_identifier)
2666 .await
2667 .inspect_err(|err| {
2668 tracing::error!(
2669 "failed to drop iceberg table {} after create iceberg engine table failed: {}",
2670 table_identifier,
2671 err.as_report()
2672 );
2673 });
2674 res?
2675 }
2676
2677 Ok(())
2678}
2679
2680pub fn check_create_table_with_source(
2681 with_options: &WithOptions,
2682 format_encode: Option<FormatEncodeOptions>,
2683 include_column_options: &IncludeOption,
2684 cdc_table_info: &Option<CdcTableInfo>,
2685) -> Result<Option<FormatEncodeOptions>> {
2686 if cdc_table_info.is_some() {
2688 return Ok(format_encode);
2689 }
2690 let defined_source = with_options.is_source_connector();
2691
2692 if !include_column_options.is_empty() && !defined_source {
2693 return Err(ErrorCode::InvalidInputSyntax(
2694 "INCLUDE should be used with a connector".to_owned(),
2695 )
2696 .into());
2697 }
2698 if defined_source {
2699 format_encode.as_ref().ok_or_else(|| {
2700 ErrorCode::InvalidInputSyntax("Please specify a source schema using FORMAT".to_owned())
2701 })?;
2702 }
2703 Ok(format_encode)
2704}
2705
2706fn ensure_partition_columns_are_prefix_of_primary_key(
2707 partition_columns: &[String],
2708 primary_key_columns: &[String],
2709) -> std::result::Result<(), String> {
2710 if partition_columns.len() > primary_key_columns.len() {
2711 return Err("Partition columns cannot be longer than primary key columns.".to_owned());
2712 }
2713
2714 for (i, partition_col) in partition_columns.iter().enumerate() {
2715 if primary_key_columns.get(i) != Some(partition_col) {
2716 return Err(format!(
2717 "Partition column '{}' is not a prefix of the primary key.",
2718 partition_col
2719 ));
2720 }
2721 }
2722
2723 Ok(())
2724}
2725
2726#[allow(clippy::too_many_arguments)]
2727pub async fn generate_stream_graph_for_replace_table(
2728 _session: &Arc<SessionImpl>,
2729 table_name: ObjectName,
2730 original_catalog: &Arc<TableCatalog>,
2731 handler_args: HandlerArgs,
2732 statement: Statement,
2733 col_id_gen: ColumnIdGenerator,
2734 sql_column_strategy: SqlColumnStrategy,
2735) -> Result<(
2736 StreamFragmentGraph,
2737 TableCatalog,
2738 Option<SourceCatalog>,
2739 TableJobType,
2740)> {
2741 let Statement::CreateTable {
2742 columns,
2743 constraints,
2744 source_watermarks,
2745 append_only,
2746 on_conflict,
2747 with_version_columns,
2748 wildcard_idx,
2749 cdc_table_info,
2750 format_encode,
2751 include_column_options,
2752 engine,
2753 with_options,
2754 ..
2755 } = statement
2756 else {
2757 panic!("unexpected statement type: {:?}", statement);
2758 };
2759
2760 let format_encode = format_encode
2761 .clone()
2762 .map(|format_encode| format_encode.into_v2_with_warning());
2763
2764 let engine = match engine {
2765 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
2766 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
2767 };
2768
2769 let is_drop_connector =
2770 original_catalog.associated_source_id().is_some() && format_encode.is_none();
2771 if is_drop_connector {
2772 debug_assert!(
2773 source_watermarks.is_empty()
2774 && include_column_options.is_empty()
2775 && with_options
2776 .iter()
2777 .all(|opt| opt.name.real_value().to_lowercase() != "connector")
2778 );
2779 }
2780
2781 let props = CreateTableProps {
2782 definition: handler_args.normalized_sql.clone(),
2783 append_only,
2784 on_conflict: on_conflict.into(),
2785 with_version_columns: with_version_columns
2786 .iter()
2787 .map(|col| col.real_value())
2788 .collect(),
2789 webhook_info: original_catalog.webhook_info.clone(),
2790 engine,
2791 };
2792
2793 let ((plan, mut source, mut table), job_type) = match (format_encode, cdc_table_info.as_ref()) {
2794 (Some(format_encode), None) => (
2795 gen_create_table_plan_with_source(
2796 handler_args,
2797 ExplainOptions::default(),
2798 table_name,
2799 columns,
2800 wildcard_idx,
2801 constraints,
2802 format_encode,
2803 source_watermarks,
2804 col_id_gen,
2805 include_column_options,
2806 props,
2807 sql_column_strategy,
2808 )
2809 .await?,
2810 TableJobType::General,
2811 ),
2812 (None, None) => {
2813 let context = OptimizerContext::from_handler_args(handler_args);
2814 let (plan, table) = gen_create_table_plan(
2815 context,
2816 table_name,
2817 columns,
2818 constraints,
2819 col_id_gen,
2820 source_watermarks,
2821 props,
2822 true,
2823 )?;
2824 ((plan, None, table), TableJobType::General)
2825 }
2826 (None, Some(cdc_table)) => {
2827 sanity_check_for_table_on_cdc_source(
2828 append_only,
2829 &columns,
2830 &wildcard_idx,
2831 &constraints,
2832 &source_watermarks,
2833 )?;
2834
2835 let session = &handler_args.session;
2836 let (source, resolved_table_name) =
2837 get_source_and_resolved_table_name(session, cdc_table.clone(), table_name.clone())?;
2838
2839 let (cdc_with_options, normalized_external_table_name) =
2840 derive_with_options_for_cdc_table(
2841 &source.with_properties,
2842 cdc_table.external_table_name.clone(),
2843 )?;
2844
2845 let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
2846
2847 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
2850
2851 let context: OptimizerContextRef =
2852 OptimizerContext::new(handler_args, ExplainOptions::default()).into();
2853 let (plan, table) = gen_create_table_plan_for_cdc_table(
2854 context,
2855 source,
2856 normalized_external_table_name,
2857 columns,
2858 source_watermarks,
2859 column_catalogs,
2860 pk_names,
2861 cdc_with_options,
2862 col_id_gen,
2863 on_conflict,
2864 with_version_columns
2865 .iter()
2866 .map(|col| col.real_value())
2867 .collect(),
2868 include_column_options,
2869 table_name,
2870 resolved_table_name,
2871 original_catalog.database_id,
2872 original_catalog.schema_id,
2873 original_catalog.id(),
2874 engine,
2875 )?;
2876
2877 ((plan, None, table), TableJobType::SharedCdcSource)
2878 }
2879 (Some(_), Some(_)) => {
2880 return Err(ErrorCode::NotSupported(
2881 "Data format and encoding format doesn't apply to table created from a CDC source"
2882 .into(),
2883 "Remove the FORMAT and ENCODE specification".into(),
2884 )
2885 .into());
2886 }
2887 };
2888
2889 if table.pk_column_ids() != original_catalog.pk_column_ids() {
2890 Err(ErrorCode::InvalidInputSyntax(
2891 "alter primary key of table is not supported".to_owned(),
2892 ))?
2893 }
2894
2895 let graph = build_graph(plan, Some(GraphJobType::Table))?;
2896
2897 table.id = original_catalog.id();
2899 if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2900 table.associated_source_id = Some(source_id);
2901
2902 let source = source.as_mut().unwrap();
2903 source.id = source_id;
2904 source.associated_table_id = Some(table.id());
2905 }
2906
2907 Ok((graph, table, source, job_type))
2908}
2909
2910fn get_source_and_resolved_table_name(
2911 session: &Arc<SessionImpl>,
2912 cdc_table: CdcTableInfo,
2913 table_name: ObjectName,
2914) -> Result<(Arc<SourceCatalog>, String)> {
2915 let db_name = &session.database();
2916 let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2917
2918 let (source_schema, source_name) =
2919 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2920
2921 let source = {
2922 let catalog_reader = session.env().catalog_reader().read_guard();
2923 let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2924 let (source, _) = catalog_reader.get_source_by_name(
2925 db_name,
2926 SchemaPath::Name(schema_name.as_str()),
2927 source_name.as_str(),
2928 )?;
2929 source.clone()
2930 };
2931
2932 Ok((source, resolved_table_name))
2933}
2934
2935fn bind_webhook_info(
2937 session: &Arc<SessionImpl>,
2938 column_defs: &[ColumnDef],
2939 webhook_info: WebhookSourceInfo,
2940) -> Result<PbWebhookSourceInfo> {
2941 let WebhookSourceInfo {
2942 secret_ref,
2943 signature_expr,
2944 wait_for_persistence,
2945 is_batched,
2946 } = webhook_info;
2947
2948 for column in column_defs {
2949 for option_def in &column.options {
2950 match option_def.option {
2951 ColumnOption::Null => {}
2952 ColumnOption::GeneratedColumns(_) => {
2953 return Err(ErrorCode::InvalidInputSyntax(
2954 "generated columns are not supported for webhook tables".to_owned(),
2955 )
2956 .into());
2957 }
2958 ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2959 return Err(ErrorCode::InvalidInputSyntax(
2960 "default values are not supported for webhook tables".to_owned(),
2961 )
2962 .into());
2963 }
2964 ColumnOption::NotNull
2965 | ColumnOption::Unique { .. }
2966 | ColumnOption::ForeignKey { .. }
2967 | ColumnOption::Check(_)
2968 | ColumnOption::DialectSpecific(_) => {
2969 return Err(ErrorCode::InvalidInputSyntax(
2970 "only NULL column option is supported for webhook tables".to_owned(),
2971 )
2972 .into());
2973 }
2974 }
2975 }
2976 }
2977
2978 let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2980 let db_name = &session.database();
2981 let (schema_name, secret_name) =
2982 Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2983 let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2984 (
2985 Some(PbSecretRef {
2986 secret_id: secret_catalog.id,
2987 ref_as: match secret_ref.ref_as {
2988 SecretRefAsType::Text => PbRefAsType::Text,
2989 SecretRefAsType::File => PbRefAsType::File,
2990 }
2991 .into(),
2992 }),
2993 Some(secret_name),
2994 )
2995 } else {
2996 (None, None)
2997 };
2998
2999 let signature_expr = if let Some(signature_expr) = signature_expr {
3000 let payload_name = if column_defs.len() == 1
3001 && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
3002 {
3003 column_defs[0].name.real_value()
3004 } else {
3005 WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
3006 };
3007 let secure_compare_context = SecureCompareContext {
3008 payload_name,
3009 secret_name,
3010 };
3011 let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
3012 let expr = binder.bind_expr(&signature_expr)?;
3013
3014 if expr.as_function_call().is_none()
3016 || expr.as_function_call().unwrap().func_type()
3017 != crate::optimizer::plan_node::generic::ExprType::SecureCompare
3018 {
3019 return Err(ErrorCode::InvalidInputSyntax(
3020 "The signature verification function must be SECURE_COMPARE()".to_owned(),
3021 )
3022 .into());
3023 }
3024
3025 Some(expr.to_expr_proto())
3026 } else {
3027 session.notice_to_user(
3028 "VALIDATE clause is strongly recommended for safety or production usages",
3029 );
3030 None
3031 };
3032
3033 let pb_webhook_info = PbWebhookSourceInfo {
3034 secret_ref: pb_secret_ref,
3035 signature_expr,
3036 wait_for_persistence,
3037 is_batched,
3038 };
3039
3040 Ok(pb_webhook_info)
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045 use risingwave_common::catalog::{
3046 DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
3047 };
3048 use risingwave_common::types::{DataType, StructType};
3049
3050 use super::*;
3051 use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
3052
3053 fn test_schema_table_name() -> SchemaTableName {
3054 SchemaTableName {
3055 schema_name: "public".to_owned(),
3056 table_name: "orders".to_owned(),
3057 }
3058 }
3059
3060 fn pk_names() -> Vec<String> {
3061 vec!["plan_id".to_owned(), "site_id".to_owned()]
3062 }
3063
3064 #[test]
3065 fn test_debezium_filter_rejects_literal_excluded_pk() {
3066 let err = reject_pk_filtered_by_debezium_column_filter_inner(
3067 &pk_names(),
3068 &test_schema_table_name(),
3069 Some("public.orders.site_id"),
3070 None,
3071 )
3072 .unwrap_err();
3073
3074 assert!(err.to_report_string().contains("site_id"));
3075 assert!(
3076 err.to_report_string()
3077 .contains("debezium.column.exclude.list")
3078 );
3079 }
3080
3081 #[test]
3082 fn test_debezium_filter_rejects_include_list_missing_pk() {
3083 let err = reject_pk_filtered_by_debezium_column_filter_inner(
3084 &pk_names(),
3085 &test_schema_table_name(),
3086 None,
3087 Some("public.orders.plan_id,public.orders.payload"),
3088 )
3089 .unwrap_err();
3090
3091 assert!(err.to_report_string().contains("site_id"));
3092 assert!(
3093 err.to_report_string()
3094 .contains("debezium.column.include.list")
3095 );
3096 }
3097
3098 #[test]
3099 fn test_debezium_filter_accepts_include_list_covering_all_pks() {
3100 reject_pk_filtered_by_debezium_column_filter_inner(
3101 &pk_names(),
3102 &test_schema_table_name(),
3103 None,
3104 Some("public.orders.plan_id,public.orders.site_id,public.orders.payload"),
3105 )
3106 .unwrap();
3107 }
3108
3109 #[test]
3110 fn test_debezium_filter_matches_regex_patterns() {
3111 reject_pk_filtered_by_debezium_column_filter_inner(
3112 &pk_names(),
3113 &test_schema_table_name(),
3114 None,
3115 Some(r"public[.]orders[.](plan_id|site_id),public[.]orders[.]payload"),
3116 )
3117 .unwrap();
3118
3119 let err = reject_pk_filtered_by_debezium_column_filter_inner(
3120 &pk_names(),
3121 &test_schema_table_name(),
3122 Some(r".*[.]orders[.]site_id"),
3123 None,
3124 )
3125 .unwrap_err();
3126
3127 assert!(err.to_report_string().contains("site_id"));
3128 }
3129
3130 #[test]
3131 fn test_debezium_filter_matches_patterns_case_insensitively() {
3132 let err = reject_pk_filtered_by_debezium_column_filter_inner(
3133 &pk_names(),
3134 &test_schema_table_name(),
3135 Some("PUBLIC.ORDERS.SITE_ID"),
3136 None,
3137 )
3138 .unwrap_err();
3139
3140 assert!(err.to_report_string().contains("site_id"));
3141
3142 reject_pk_filtered_by_debezium_column_filter_inner(
3143 &pk_names(),
3144 &test_schema_table_name(),
3145 None,
3146 Some("Public.Orders.Plan_ID,Public.Orders.Site_ID"),
3147 )
3148 .unwrap();
3149 }
3150
3151 #[tokio::test]
3152 async fn test_create_table_handler() {
3153 let sql =
3154 "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
3155 let frontend = LocalFrontend::new(Default::default()).await;
3156 frontend.run_sql(sql).await.unwrap();
3157
3158 let session = frontend.session_ref();
3159 let catalog_reader = session.env().catalog_reader().read_guard();
3160 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
3161
3162 let (table, _) = catalog_reader
3164 .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
3165 .unwrap();
3166 assert_eq!(table.name(), "t");
3167
3168 let columns = table
3169 .columns
3170 .iter()
3171 .map(|col| (col.name(), col.data_type().clone()))
3172 .collect::<HashMap<&str, DataType>>();
3173
3174 let expected_columns = maplit::hashmap! {
3175 ROW_ID_COLUMN_NAME => DataType::Serial,
3176 "v1" => DataType::Int16,
3177 "v2" => StructType::new(
3178 vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
3179 )
3180 .with_ids([3, 4, 5].map(ColumnId::new))
3181 .into(),
3182 RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
3183 };
3184
3185 assert_eq!(columns, expected_columns, "{columns:#?}");
3186 }
3187
3188 #[tokio::test]
3189 async fn test_create_webhook_table_with_arbitrary_columns() {
3190 let frontend = LocalFrontend::new(Default::default()).await;
3191 frontend
3192 .run_sql("create schema ingest_schema;")
3193 .await
3194 .unwrap();
3195 frontend
3196 .run_sql(
3197 r#"
3198 create table ingest_schema.orders (
3199 id int,
3200 customer_name varchar,
3201 amount double precision,
3202 primary key (id)
3203 ) with (
3204 connector = 'webhook'
3205 ) validate as secure_compare(
3206 headers->>'x-rw-signature',
3207 'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
3208 );
3209 "#,
3210 )
3211 .await
3212 .unwrap();
3213
3214 let session = frontend.session_ref();
3215 let catalog_reader = session.env().catalog_reader().read_guard();
3216 let (table, _) = catalog_reader
3217 .get_created_table_by_name(
3218 DEFAULT_DATABASE_NAME,
3219 SchemaPath::Name("ingest_schema"),
3220 "orders",
3221 )
3222 .unwrap();
3223
3224 assert!(table.webhook_info.is_some());
3225 assert_eq!(
3226 table
3227 .columns
3228 .iter()
3229 .filter(|column| column.can_dml())
3230 .count(),
3231 3
3232 );
3233 }
3234
3235 #[tokio::test]
3236 async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
3237 let frontend = LocalFrontend::new(Default::default()).await;
3238 frontend
3239 .run_sql(
3240 r#"
3241 create table webhook_single_column (
3242 body jsonb
3243 ) with (
3244 connector = 'webhook'
3245 ) validate as secure_compare(
3246 headers->>'x-rw-signature',
3247 'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
3248 );
3249 "#,
3250 )
3251 .await
3252 .unwrap();
3253 }
3254
3255 #[tokio::test]
3256 async fn test_create_webhook_table_with_generated_columns() {
3257 let frontend = LocalFrontend::new(Default::default()).await;
3258 let err = frontend
3259 .run_sql(
3260 r#"
3261 create table webhook_generated_columns (
3262 id int,
3263 amount double precision,
3264 amount_with_fee double precision as amount + 1.0
3265 ) with (
3266 connector = 'webhook'
3267 );
3268 "#,
3269 )
3270 .await
3271 .unwrap_err();
3272
3273 assert!(
3274 err.to_string()
3275 .contains("generated columns are not supported for webhook tables"),
3276 "{err:?}"
3277 );
3278 }
3279
3280 #[tokio::test]
3281 async fn test_create_webhook_table_with_default_value() {
3282 let frontend = LocalFrontend::new(Default::default()).await;
3283 let err = frontend
3284 .run_sql(
3285 r#"
3286 create table webhook_default_value (
3287 id int default 42,
3288 amount double precision
3289 ) with (
3290 connector = 'webhook'
3291 );
3292 "#,
3293 )
3294 .await
3295 .unwrap_err();
3296
3297 assert!(
3298 err.to_string()
3299 .contains("default values are not supported for webhook tables"),
3300 "{err:?}"
3301 );
3302 }
3303
3304 #[tokio::test]
3305 async fn test_create_webhook_table_with_not_null_option() {
3306 let frontend = LocalFrontend::new(Default::default()).await;
3307 let err = frontend
3308 .run_sql(
3309 r#"
3310 create table webhook_not_null (
3311 id int not null,
3312 amount double precision
3313 ) with (
3314 connector = 'webhook'
3315 );
3316 "#,
3317 )
3318 .await
3319 .unwrap_err();
3320
3321 assert!(
3322 err.to_string()
3323 .contains("only NULL column option is supported for webhook tables"),
3324 "{err:?}"
3325 );
3326 }
3327
3328 #[test]
3329 fn test_parse_postgres_cdc_external_table_name() {
3330 for (input, expected) in [
3331 ("public.Note", ("public", "Note")),
3332 ("public.\"Note\"", ("public", "Note")),
3333 (
3334 "\"Mixed.Schema\".\"Note.Table\"",
3335 ("Mixed.Schema", "Note.Table"),
3336 ),
3337 ("public.\"Note\"\"Archive\"", ("public", "Note\"Archive")),
3338 ] {
3339 assert_eq!(
3340 parse_postgres_cdc_external_table_name(input).unwrap(),
3341 (expected.0.to_owned(), expected.1.to_owned()),
3342 "input: {input}"
3343 );
3344 }
3345
3346 for input in [
3347 "Note",
3348 "public.",
3349 ".Note",
3350 "public.\"Note",
3351 "public.\"Note\"Archive",
3352 "public.Note.Archive",
3353 ] {
3354 assert!(
3355 parse_postgres_cdc_external_table_name(input).is_err(),
3356 "input should be rejected: {input}"
3357 );
3358 }
3359 }
3360
3361 #[test]
3362 fn test_bind_primary_key() {
3363 for (sql, expected) in [
3366 ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
3367 ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
3368 ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
3369 (
3370 "create table t (v1 int primary key, v2 int primary key)",
3371 Err("multiple primary keys are not allowed"),
3372 ),
3373 (
3374 "create table t (v1 int primary key primary key, v2 int)",
3375 Err("multiple primary keys are not allowed"),
3376 ),
3377 (
3378 "create table t (v1 int, v2 int, primary key (v1))",
3379 Ok(&[1]),
3380 ),
3381 (
3382 "create table t (v1 int, primary key (v2), v2 int)",
3383 Ok(&[2]),
3384 ),
3385 (
3386 "create table t (primary key (v2, v1), v1 int, v2 int)",
3387 Ok(&[2, 1]),
3388 ),
3389 (
3390 "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
3391 Err("multiple primary keys are not allowed"),
3392 ),
3393 (
3394 "create table t (v1 int primary key, primary key (v1), v2 int)",
3395 Err("multiple primary keys are not allowed"),
3396 ),
3397 (
3398 "create table t (v1 int, primary key (V3), v2 int)",
3399 Err("column \"v3\" named in key does not exist"),
3400 ),
3401 ] {
3402 let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
3403 let risingwave_sqlparser::ast::Statement::CreateTable {
3404 columns: column_defs,
3405 constraints,
3406 ..
3407 } = ast.remove(0)
3408 else {
3409 panic!("test case should be create table")
3410 };
3411 let actual: Result<_> = (|| {
3412 let mut columns = bind_sql_columns(&column_defs, false)?;
3413 let mut col_id_gen = ColumnIdGenerator::new_initial();
3414 for c in &mut columns {
3415 col_id_gen.generate(c)?;
3416 }
3417
3418 let pk_names =
3419 bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
3420 let (_, pk_column_ids, _) =
3421 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
3422 Ok(pk_column_ids)
3423 })();
3424 match (expected, actual) {
3425 (Ok(expected), Ok(actual)) => assert_eq!(
3426 expected.iter().copied().map(ColumnId::new).collect_vec(),
3427 actual,
3428 "sql: {sql}"
3429 ),
3430 (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
3431 (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
3432 (Err(expected), Err(actual)) => assert!(
3433 actual.to_string().contains(expected),
3434 "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
3435 ),
3436 }
3437 }
3438 }
3439
3440 #[tokio::test]
3441 async fn test_duplicate_props_options() {
3442 let proto_file = create_proto_file(PROTO_FILE_DATA);
3443 let sql = format!(
3444 r#"CREATE TABLE t
3445 WITH (
3446 connector = 'kinesis',
3447 aws.region='user_test_topic',
3448 endpoint='172.10.1.1:9090,172.10.1.2:9090',
3449 aws.credentials.access_key_id = 'your_access_key_1',
3450 aws.credentials.secret_access_key = 'your_secret_key_1'
3451 )
3452 FORMAT PLAIN ENCODE PROTOBUF (
3453 message = '.test.TestRecord',
3454 aws.credentials.access_key_id = 'your_access_key_2',
3455 aws.credentials.secret_access_key = 'your_secret_key_2',
3456 schema.location = 'file://{}',
3457 )"#,
3458 proto_file.path().to_str().unwrap()
3459 );
3460 let frontend = LocalFrontend::new(Default::default()).await;
3461 frontend.run_sql(sql).await.unwrap();
3462
3463 let session = frontend.session_ref();
3464 let catalog_reader = session.env().catalog_reader().read_guard();
3465 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
3466
3467 let (source, _) = catalog_reader
3469 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
3470 .unwrap();
3471 assert_eq!(source.name, "t");
3472
3473 assert_eq!(
3475 source
3476 .info
3477 .format_encode_options
3478 .get("aws.credentials.access_key_id")
3479 .unwrap(),
3480 "your_access_key_2"
3481 );
3482 assert_eq!(
3483 source
3484 .info
3485 .format_encode_options
3486 .get("aws.credentials.secret_access_key")
3487 .unwrap(),
3488 "your_secret_key_2"
3489 );
3490
3491 assert_eq!(
3493 source
3494 .with_properties
3495 .get("aws.credentials.access_key_id")
3496 .unwrap(),
3497 "your_access_key_1"
3498 );
3499 assert_eq!(
3500 source
3501 .with_properties
3502 .get("aws.credentials.secret_access_key")
3503 .unwrap(),
3504 "your_secret_key_1"
3505 );
3506
3507 assert!(!source.with_properties.contains_key("schema.location"));
3509 }
3510}