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