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 CdcKeyComparison, CdcTableDesc, ColumnCatalog, ColumnDesc, ConflictBehavior,
32 DEFAULT_SCHEMA_NAME, Engine, ICEBERG_SINK_PREFIX, ICEBERG_SOURCE_PREFIX,
33 RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME, 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 pk_comparisons: Vec<CdcKeyComparison>,
840 cdc_with_options: WithOptionsSecResolved,
841 mut col_id_gen: ColumnIdGenerator,
842 on_conflict: Option<OnConflict>,
843 with_version_columns: Vec<String>,
844 include_column_options: IncludeOption,
845 table_name: ObjectName,
846 resolved_table_name: String, database_id: DatabaseId,
848 schema_id: SchemaId,
849 table_id: TableId,
850 engine: Engine,
851) -> Result<(PlanRef, TableCatalog)> {
852 let session = context.session_ctx().clone();
853
854 handle_addition_columns(
856 None,
857 &cdc_with_options,
858 include_column_options,
859 &mut columns,
860 true,
861 )?;
862
863 for c in &mut columns {
864 col_id_gen.generate(c)?;
865 }
866
867 let (mut columns, pk_column_ids, _row_id_index) =
868 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
869
870 let watermark_descs = bind_source_watermark(
871 context.session_ctx(),
872 table_name.real_value(),
873 source_watermarks,
874 &columns,
875 )?;
876
877 bind_sql_column_constraints(
879 context.session_ctx(),
880 table_name.real_value(),
881 &mut columns,
882 &column_defs,
883 &pk_column_ids,
884 )?;
885
886 let definition = context.normalized_sql().to_owned();
887
888 let pk_column_indices = {
889 let mut id_to_idx = HashMap::new();
890 columns.iter().enumerate().for_each(|(idx, c)| {
891 id_to_idx.insert(c.column_id(), idx);
892 });
893 pk_column_ids
895 .iter()
896 .map(|c| id_to_idx.get(c).copied().unwrap())
897 .collect_vec()
898 };
899 let table_pk = pk_column_indices
900 .iter()
901 .map(|idx| ColumnOrder::new(*idx, OrderType::ascending()))
902 .collect();
903
904 let (options, secret_refs) = cdc_with_options.into_parts();
905
906 let non_generated_column_descs = columns
907 .iter()
908 .filter(|&c| !c.is_generated())
909 .map(|c| c.column_desc.clone())
910 .collect_vec();
911 let non_generated_column_num = non_generated_column_descs.len();
912 let cdc_table_type = ExternalCdcTableType::from_properties(&options);
913 let cdc_table_desc = CdcTableDesc {
914 table_id,
915 source_id: source.id, external_table_name: external_table_name.clone(),
917 pk: table_pk,
918 pk_comparisons,
919 columns: non_generated_column_descs,
920 stream_key: pk_column_indices,
921 connect_properties: options,
922 secret_refs,
923 };
924
925 tracing::debug!(?cdc_table_desc, "create cdc table");
926 let options = build_cdc_scan_options_with_options(context.with_options(), &cdc_table_type)?;
927
928 let logical_scan = LogicalCdcScan::create(
929 external_table_name.clone(),
930 Rc::new(cdc_table_desc),
931 context.clone(),
932 options,
933 );
934
935 let scan_node: LogicalPlanRef = logical_scan.into();
936 let required_cols = FixedBitSet::with_capacity(non_generated_column_num);
937 let plan_root = PlanRoot::new_with_logical_plan(
938 scan_node,
939 RequiredDist::Any,
940 Order::any(),
941 required_cols,
942 vec![],
943 );
944
945 let cdc_table_id_external_table_name = if let ExternalCdcTableType::Postgres = cdc_table_type
946 && let Some(normalized_table_name) =
947 normalize_simple_postgres_quoted_table_name(&external_table_name)
948 {
949 normalized_table_name
950 } else {
951 external_table_name
952 };
953 let cdc_table_id = build_cdc_table_id(source.id, &cdc_table_id_external_table_name);
954 let materialize = plan_root.gen_table_plan(
955 context,
956 resolved_table_name,
957 database_id,
958 schema_id,
959 CreateTableInfo {
960 columns,
961 pk_column_ids,
962 row_id_index: None,
963 watermark_descs,
964 source_catalog: Some((*source).clone()),
965 version: col_id_gen.into_version(),
966 },
967 CreateTableProps {
968 definition,
969 append_only: false,
970 on_conflict: on_conflict.into(),
971 with_version_columns,
972 webhook_info: None,
973 engine,
974 },
975 )?;
976
977 let mut table = materialize.table().clone();
978 table.owner = session.user_id();
979 table.cdc_table_id = Some(cdc_table_id);
980 table.cdc_table_type = Some(cdc_table_type);
981 Ok((
982 ensure_sync_log_store_fragment_root(materialize.into()),
983 table,
984 ))
985}
986
987fn derive_with_options_for_cdc_table(
995 source_with_properties: &WithOptionsSecResolved,
996 external_table_name: String,
997) -> Result<(WithOptionsSecResolved, String)> {
998 use source::cdc::{MYSQL_CDC_CONNECTOR, POSTGRES_CDC_CONNECTOR, SQL_SERVER_CDC_CONNECTOR};
999 let source_database_name: &str = source_with_properties
1001 .get("database.name")
1002 .ok_or_else(|| anyhow!("The source with properties does not contain 'database.name'"))?
1003 .as_str();
1004 let mut with_options = source_with_properties.clone();
1005 if let Some(connector) = source_with_properties.get(UPSTREAM_SOURCE_KEY) {
1006 match connector.as_str() {
1007 MYSQL_CDC_CONNECTOR => {
1008 let (db_name, table_name) = external_table_name.split_once('.').ok_or_else(|| {
1011 anyhow!("The upstream table name must contain database name prefix, e.g. 'database.table'")
1012 })?;
1013 if !source_database_name
1015 .split(',')
1016 .map(|s| s.trim())
1017 .any(|name| name == db_name)
1018 {
1019 return Err(anyhow!(
1020 "The database name `{}` in the FROM clause is not included in the database name `{}` in source definition",
1021 db_name,
1022 source_database_name
1023 ).into());
1024 }
1025 with_options.insert(DATABASE_NAME_KEY.into(), db_name.into());
1026 with_options.insert(TABLE_NAME_KEY.into(), table_name.into());
1027 return Ok((with_options, external_table_name));
1029 }
1030 POSTGRES_CDC_CONNECTOR => {
1031 let (schema_name, table_name) =
1032 parse_postgres_cdc_external_table_name(&external_table_name)?;
1033
1034 with_options.insert(SCHEMA_NAME_KEY.into(), schema_name);
1036 with_options.insert(TABLE_NAME_KEY.into(), table_name);
1037 return Ok((with_options, external_table_name));
1039 }
1040 SQL_SERVER_CDC_CONNECTOR => {
1041 let parts: Vec<&str> = external_table_name.split('.').collect();
1049 let (schema_name, table_name) = match parts.len() {
1050 3 => {
1051 let db_name = parts[0];
1054 let schema_name = parts[1];
1055 let table_name = parts[2];
1056
1057 if db_name != source_database_name {
1058 return Err(anyhow!(
1059 "The database name '{}' in FROM clause does not match the database name '{}' specified in source definition. \
1060 You can either use 'schema.table' format (recommended) or ensure the database name matches.",
1061 db_name,
1062 source_database_name
1063 ).into());
1064 }
1065 (schema_name, table_name)
1066 }
1067 2 => {
1068 let schema_name = parts[0];
1071 let table_name = parts[1];
1072 (schema_name, table_name)
1073 }
1074 1 => {
1075 return Err(anyhow!(
1078 "Invalid table name format '{}'. For SQL Server CDC, you must specify the schema name. \
1079 Use 'schema.table' format (e.g., 'dbo.{}') or 'database.schema.table' format (e.g., '{}.dbo.{}').",
1080 external_table_name,
1081 external_table_name,
1082 source_database_name,
1083 external_table_name
1084 ).into());
1085 }
1086 _ => {
1087 return Err(anyhow!(
1089 "Invalid table name format '{}'. Expected 'schema.table' or 'database.schema.table'.",
1090 external_table_name
1091 ).into());
1092 }
1093 };
1094
1095 with_options.insert(SCHEMA_NAME_KEY.into(), schema_name.into());
1097 with_options.insert(TABLE_NAME_KEY.into(), table_name.into());
1098
1099 let normalized_external_table_name = format!("{}.{}", schema_name, table_name);
1102 return Ok((with_options, normalized_external_table_name));
1103 }
1104 _ => {
1105 return Err(RwError::from(anyhow!(
1106 "connector {} is not supported for cdc table",
1107 connector
1108 )));
1109 }
1110 };
1111 }
1112 unreachable!("All valid CDC connectors should have returned by now")
1113}
1114
1115fn parse_postgres_cdc_external_table_name(external_table_name: &str) -> Result<(String, String)> {
1120 let mut parts = vec![];
1121 let mut current = String::new();
1122 let mut chars = external_table_name.chars().peekable();
1123 let mut in_quote = false;
1124 let mut just_closed_quote = false;
1125
1126 while let Some(ch) = chars.next() {
1127 if in_quote {
1128 if ch == '"' {
1129 if chars.peek() == Some(&'"') {
1130 current.push('"');
1131 chars.next();
1132 } else {
1133 in_quote = false;
1134 just_closed_quote = true;
1135 }
1136 } else {
1137 current.push(ch);
1138 }
1139 } else {
1140 match ch {
1141 '.' => {
1142 if current.is_empty() {
1143 return Err(anyhow!(
1144 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1145 external_table_name
1146 )
1147 .into());
1148 }
1149 parts.push(std::mem::take(&mut current));
1150 just_closed_quote = false;
1151 }
1152 '"' if current.is_empty() => {
1153 in_quote = true;
1154 }
1155 '"' => {
1156 return Err(anyhow!(
1157 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1158 external_table_name
1159 )
1160 .into());
1161 }
1162 _ if just_closed_quote => {
1163 return Err(anyhow!(
1164 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1165 external_table_name
1166 )
1167 .into());
1168 }
1169 _ => current.push(ch),
1170 }
1171 }
1172 }
1173
1174 if in_quote || current.is_empty() {
1175 return Err(anyhow!(
1176 "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1177 external_table_name
1178 )
1179 .into());
1180 }
1181 parts.push(current);
1182
1183 if let [schema_name, table_name] = parts.as_slice() {
1184 Ok((schema_name.clone(), table_name.clone()))
1185 } else {
1186 Err(
1187 anyhow!("The upstream table name must contain schema name prefix, e.g. 'public.table'")
1188 .into(),
1189 )
1190 }
1191}
1192
1193fn reject_pk_filtered_by_debezium_column_filter(
1206 pk_names: &[String],
1207 cdc_with_options: &WithOptionsSecResolved,
1208) -> Result<()> {
1209 const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
1210 const INCLUDE_KEY: &str = "debezium.column.include.list";
1211
1212 let st = SchemaTableName::from_properties(cdc_with_options.as_plaintext());
1213 reject_pk_filtered_by_debezium_column_filter_inner(
1214 pk_names,
1215 &st,
1216 cdc_with_options.get(EXCLUDE_KEY).map(String::as_str),
1217 cdc_with_options.get(INCLUDE_KEY).map(String::as_str),
1218 )
1219}
1220
1221fn reject_pk_filtered_by_debezium_column_filter_inner(
1222 pk_names: &[String],
1223 st: &SchemaTableName,
1224 exclude_list: Option<&str>,
1225 include_list: Option<&str>,
1226) -> Result<()> {
1227 const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
1228 const INCLUDE_KEY: &str = "debezium.column.include.list";
1229
1230 let pk_full_names = pk_names
1231 .iter()
1232 .map(|pk| (pk, format!("{}.{}.{}", st.schema_name, st.table_name, pk)))
1233 .collect_vec();
1234
1235 if let Some(exclude_list) = exclude_list {
1236 let patterns = compile_debezium_column_filter_patterns(EXCLUDE_KEY, exclude_list)?;
1237 for (pk, pk_full_name) in &pk_full_names {
1238 for (pattern, regex) in &patterns {
1239 if regex.is_match(pk_full_name).map_err(|err| {
1240 ErrorCode::InvalidInputSyntax(format!(
1241 "failed to evaluate Debezium column filter pattern `{pattern}` in `{EXCLUDE_KEY}`: {}",
1242 err.as_report()
1243 ))
1244 })? {
1245 return Err(ErrorCode::InvalidInputSyntax(format!(
1246 "primary key column `{pk}` is excluded by `{EXCLUDE_KEY}` pattern \
1247 `{pattern}`. Excluding a PK column causes silent data corruption: \
1248 Debezium keeps the PK in the message key but drops it from the payload, \
1249 so RisingWave cannot match UPDATE/DELETE events against the original row."
1250 ))
1251 .into());
1252 }
1253 }
1254 }
1255 }
1256
1257 if let Some(include_list) = include_list {
1258 let patterns = compile_debezium_column_filter_patterns(INCLUDE_KEY, include_list)?;
1259 for (pk, pk_full_name) in &pk_full_names {
1260 let mut included = false;
1261 for (_, regex) in &patterns {
1262 if regex.is_match(pk_full_name).map_err(|err| {
1263 ErrorCode::InvalidInputSyntax(format!(
1264 "failed to evaluate Debezium column filter pattern in `{INCLUDE_KEY}`: {}",
1265 err.as_report()
1266 ))
1267 })? {
1268 included = true;
1269 break;
1270 }
1271 }
1272 if !included {
1273 return Err(ErrorCode::InvalidInputSyntax(format!(
1274 "primary key column `{pk}` is not included by `{INCLUDE_KEY}`. Omitting a PK \
1275 column causes silent data corruption: Debezium keeps the PK in the message key \
1276 but drops it from the payload, so RisingWave cannot match UPDATE/DELETE events \
1277 against the original row."
1278 ))
1279 .into());
1280 }
1281 }
1282 }
1283
1284 Ok(())
1285}
1286
1287fn compile_debezium_column_filter_patterns(
1288 key: &str,
1289 filter_list: &str,
1290) -> Result<Vec<(String, Regex)>> {
1291 filter_list
1292 .split(',')
1293 .map(str::trim)
1294 .filter(|pattern| !pattern.is_empty())
1295 .map(|pattern| {
1296 let anchored_pattern = format!("(?i:^(?:{pattern})$)");
1297 let regex = Regex::new(&anchored_pattern).map_err(|err| {
1298 ErrorCode::InvalidInputSyntax(format!(
1299 "invalid Debezium column filter pattern `{pattern}` in `{key}`: {}",
1300 err.as_report()
1301 ))
1302 })?;
1303 Ok((pattern.to_owned(), regex))
1304 })
1305 .collect()
1306}
1307
1308#[allow(clippy::too_many_arguments)]
1309pub(super) async fn handle_create_table_plan(
1310 handler_args: HandlerArgs,
1311 explain_options: ExplainOptions,
1312 format_encode: Option<FormatEncodeOptions>,
1313 cdc_table_info: Option<CdcTableInfo>,
1314 table_name: &ObjectName,
1315 column_defs: Vec<ColumnDef>,
1316 wildcard_idx: Option<usize>,
1317 constraints: Vec<TableConstraint>,
1318 source_watermarks: Vec<SourceWatermark>,
1319 append_only: bool,
1320 on_conflict: Option<OnConflict>,
1321 with_version_columns: Vec<String>,
1322 include_column_options: IncludeOption,
1323 webhook_info: Option<WebhookSourceInfo>,
1324 engine: Engine,
1325) -> Result<(
1326 PlanRef,
1327 Option<SourceCatalog>,
1328 TableCatalog,
1329 TableJobType,
1330 Option<SourceId>,
1331)> {
1332 let col_id_gen = ColumnIdGenerator::new_initial();
1333 let format_encode = check_create_table_with_source(
1334 &handler_args.with_options,
1335 format_encode,
1336 &include_column_options,
1337 &cdc_table_info,
1338 )?;
1339 let webhook_info = webhook_info
1340 .map(|info| bind_webhook_info(&handler_args.session, &column_defs, info))
1341 .transpose()?;
1342
1343 let props = CreateTableProps {
1344 definition: handler_args.normalized_sql.clone(),
1345 append_only,
1346 on_conflict: on_conflict.into(),
1347 with_version_columns: with_version_columns.clone(),
1348 webhook_info,
1349 engine,
1350 };
1351
1352 let ((plan, source, table), job_type, shared_shource_id) = match (
1353 format_encode,
1354 cdc_table_info.as_ref(),
1355 ) {
1356 (Some(format_encode), None) => (
1357 gen_create_table_plan_with_source(
1358 handler_args,
1359 explain_options,
1360 table_name.clone(),
1361 column_defs,
1362 wildcard_idx,
1363 constraints,
1364 format_encode,
1365 source_watermarks,
1366 col_id_gen,
1367 include_column_options,
1368 props,
1369 SqlColumnStrategy::FollowChecked,
1370 )
1371 .await?,
1372 TableJobType::General,
1373 None,
1374 ),
1375 (None, None) => {
1376 let context = OptimizerContext::new(handler_args, explain_options);
1377 let (plan, table) = gen_create_table_plan(
1378 context,
1379 table_name.clone(),
1380 column_defs,
1381 constraints,
1382 col_id_gen,
1383 source_watermarks,
1384 props,
1385 false,
1386 )?;
1387
1388 ((plan, None, table), TableJobType::General, None)
1389 }
1390
1391 (None, Some(cdc_table)) => {
1392 sanity_check_for_table_on_cdc_source(
1393 append_only,
1394 &column_defs,
1395 &wildcard_idx,
1396 &constraints,
1397 &source_watermarks,
1398 )?;
1399
1400 generated_columns_check_for_cdc_table(&column_defs)?;
1401 not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
1402
1403 let session = &handler_args.session;
1404 let db_name = &session.database();
1405 let user_name = &session.user_name();
1406 let search_path = session.config().search_path();
1407 let (schema_name, resolved_table_name) =
1408 Binder::resolve_schema_qualified_name(db_name, table_name)?;
1409 let (database_id, schema_id) =
1410 session.get_database_and_schema_id_for_create(schema_name.clone())?;
1411
1412 let (source_schema, source_name) =
1414 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
1415
1416 let source = {
1417 let catalog_reader = session.env().catalog_reader().read_guard();
1418 let schema_path =
1419 SchemaPath::new(source_schema.as_deref(), &search_path, user_name);
1420
1421 let (source, _) = catalog_reader.get_source_by_name(
1422 db_name,
1423 schema_path,
1424 source_name.as_str(),
1425 )?;
1426 source.clone()
1427 };
1428 check_cdc_source_select_privilege(session, &source)?;
1429 let (cdc_with_options, normalized_external_table_name) =
1430 derive_with_options_for_cdc_table(
1431 &source.with_properties,
1432 cdc_table.external_table_name.clone(),
1433 )?;
1434
1435 let (columns, pk_names, pk_comparisons) = match wildcard_idx {
1436 Some(_) => bind_cdc_table_schema_externally(cdc_with_options.clone()).await?,
1437 None => {
1438 for column_def in &column_defs {
1439 for option_def in &column_def.options {
1440 if let ColumnOption::DefaultValue(_)
1441 | ColumnOption::DefaultValueInternal { .. } = option_def.option
1442 {
1443 return Err(ErrorCode::NotSupported(
1444 "Default value for columns defined on the table created from a CDC source".into(),
1445 "Remove the default value expression in the column definitions".into(),
1446 )
1447 .into());
1448 }
1449 }
1450 }
1451
1452 let (columns, pk_names) =
1453 bind_cdc_table_schema(&column_defs, &constraints, false)?;
1454 let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1455 cdc_with_options.clone(),
1456 &pk_names,
1457 ))
1458 .await?;
1459
1460 (columns, pk_names, pk_comparisons)
1461 }
1462 };
1463
1464 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1469
1470 let context: OptimizerContextRef =
1471 OptimizerContext::new(handler_args, explain_options).into();
1472 let shared_source_id = source.id;
1473 let (plan, table) = gen_create_table_plan_for_cdc_table(
1474 context,
1475 source,
1476 normalized_external_table_name,
1477 column_defs,
1478 source_watermarks,
1479 columns,
1480 pk_names,
1481 pk_comparisons,
1482 cdc_with_options,
1483 col_id_gen,
1484 on_conflict,
1485 with_version_columns,
1486 include_column_options,
1487 table_name.clone(),
1488 resolved_table_name,
1489 database_id,
1490 schema_id,
1491 TableId::placeholder(),
1492 engine,
1493 )?;
1494
1495 (
1496 (plan, None, table),
1497 TableJobType::SharedCdcSource,
1498 Some(shared_source_id),
1499 )
1500 }
1501 (Some(_), Some(_)) => {
1502 return Err(ErrorCode::NotSupported(
1503 "Data format and encoding format doesn't apply to table created from a CDC source"
1504 .into(),
1505 "Remove the FORMAT and ENCODE specification".into(),
1506 )
1507 .into());
1508 }
1509 };
1510 Ok((plan, source, table, job_type, shared_shource_id))
1511}
1512
1513fn generated_columns_check_for_cdc_table(columns: &Vec<ColumnDef>) -> Result<()> {
1515 let mut found_generated_column = false;
1516 for column in columns {
1517 let mut is_generated = false;
1518
1519 for option_def in &column.options {
1520 if let ColumnOption::GeneratedColumns(_) = option_def.option {
1521 is_generated = true;
1522 break;
1523 }
1524 }
1525
1526 if is_generated {
1527 found_generated_column = true;
1528 } else if found_generated_column {
1529 return Err(ErrorCode::NotSupported(
1530 "Non-generated column found after a generated column.".into(),
1531 "Ensure that all generated columns appear at the end of the cdc table definition."
1532 .into(),
1533 )
1534 .into());
1535 }
1536 }
1537 Ok(())
1538}
1539
1540fn not_null_check_for_cdc_table(
1542 wildcard_idx: &Option<usize>,
1543 column_defs: &Vec<ColumnDef>,
1544) -> Result<()> {
1545 if !wildcard_idx.is_some()
1546 && column_defs.iter().any(|col| {
1547 col.options
1548 .iter()
1549 .any(|opt| matches!(opt.option, ColumnOption::NotNull))
1550 })
1551 {
1552 return Err(ErrorCode::NotSupported(
1553 "CDC table with NOT NULL constraint is not supported".to_owned(),
1554 "Please remove the NOT NULL constraint for columns".to_owned(),
1555 )
1556 .into());
1557 }
1558 Ok(())
1559}
1560
1561fn sanity_check_for_table_on_cdc_source(
1563 append_only: bool,
1564 column_defs: &Vec<ColumnDef>,
1565 wildcard_idx: &Option<usize>,
1566 constraints: &Vec<TableConstraint>,
1567 source_watermarks: &Vec<SourceWatermark>,
1568) -> Result<()> {
1569 if wildcard_idx.is_some() && !column_defs.is_empty() {
1571 return Err(ErrorCode::NotSupported(
1572 "wildcard(*) and column definitions cannot be used together".to_owned(),
1573 "Remove the wildcard or column definitions".to_owned(),
1574 )
1575 .into());
1576 }
1577
1578 if !wildcard_idx.is_some()
1580 && !constraints.iter().any(|c| {
1581 matches!(
1582 c,
1583 TableConstraint::Unique {
1584 is_primary: true,
1585 ..
1586 }
1587 )
1588 })
1589 && !column_defs.iter().any(|col| {
1590 col.options
1591 .iter()
1592 .any(|opt| matches!(opt.option, ColumnOption::Unique { is_primary: true }))
1593 })
1594 {
1595 return Err(ErrorCode::NotSupported(
1596 "CDC table without primary key constraint is not supported".to_owned(),
1597 "Please define a primary key".to_owned(),
1598 )
1599 .into());
1600 }
1601
1602 if append_only {
1603 return Err(ErrorCode::NotSupported(
1604 "append only modifier on the table created from a CDC source".into(),
1605 "Remove the APPEND ONLY clause".into(),
1606 )
1607 .into());
1608 }
1609
1610 if !source_watermarks.is_empty()
1611 && source_watermarks
1612 .iter()
1613 .any(|watermark| !watermark.with_ttl)
1614 {
1615 return Err(ErrorCode::NotSupported(
1616 "non-TTL watermark defined on the table created from a CDC source".into(),
1617 "Use `WATERMARK ... WITH TTL` instead.".into(),
1618 )
1619 .into());
1620 }
1621
1622 Ok(())
1623}
1624
1625async fn bind_cdc_table_schema_externally(
1627 cdc_with_options: WithOptionsSecResolved,
1628) -> Result<(Vec<ColumnCatalog>, Vec<String>, Vec<CdcKeyComparison>)> {
1629 let (options, secret_refs) = cdc_with_options.into_parts();
1631 let config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
1632 .context("failed to extract external table config")?;
1633
1634 let table = ExternalTableImpl::connect(config)
1635 .await
1636 .context("failed to auto derive table schema")?;
1637
1638 let pk_names = table.pk_names().clone();
1639 let pk_comparisons = table.pk_column_comparisons(&pk_names)?;
1640 Ok((
1641 table
1642 .column_descs()
1643 .iter()
1644 .cloned()
1645 .map(|column_desc| ColumnCatalog {
1646 column_desc,
1647 is_hidden: false,
1648 })
1649 .collect(),
1650 pk_names,
1651 pk_comparisons,
1652 ))
1653}
1654
1655async fn bind_cdc_pk_comparisons_externally(
1656 cdc_with_options: WithOptionsSecResolved,
1657 pk_names: &[String],
1658) -> Result<Vec<CdcKeyComparison>> {
1659 let (options, secret_refs) = cdc_with_options.into_parts();
1662 let config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
1663 .context("failed to extract external table config")?;
1664 Ok(ExternalTableImpl::discover_pk_column_comparisons(&config, pk_names).await?)
1665}
1666
1667fn bind_cdc_table_schema(
1669 column_defs: &Vec<ColumnDef>,
1670 constraints: &Vec<TableConstraint>,
1671 is_for_replace_plan: bool,
1672) -> Result<(Vec<ColumnCatalog>, Vec<String>)> {
1673 let columns = bind_sql_columns(column_defs, is_for_replace_plan)?;
1674 reject_variant_columns(&columns, "on a table created from a CDC source")?;
1676
1677 let pk_names = bind_sql_pk_names(column_defs, bind_table_constraints(constraints)?)?;
1678 Ok((columns, pk_names))
1679}
1680
1681#[allow(clippy::too_many_arguments)]
1682pub async fn handle_create_table(
1683 handler_args: HandlerArgs,
1684 table_name: ObjectName,
1685 column_defs: Vec<ColumnDef>,
1686 wildcard_idx: Option<usize>,
1687 constraints: Vec<TableConstraint>,
1688 if_not_exists: bool,
1689 format_encode: Option<FormatEncodeOptions>,
1690 source_watermarks: Vec<SourceWatermark>,
1691 append_only: bool,
1692 on_conflict: Option<OnConflict>,
1693 with_version_columns: Vec<String>,
1694 cdc_table_info: Option<CdcTableInfo>,
1695 include_column_options: IncludeOption,
1696 webhook_info: Option<WebhookSourceInfo>,
1697 ast_engine: risingwave_sqlparser::ast::Engine,
1698) -> Result<RwPgResponse> {
1699 let session = handler_args.session.clone();
1700
1701 if append_only {
1702 session.notice_to_user("APPEND ONLY TABLE is currently an experimental feature.");
1703 }
1704
1705 session.check_cluster_limits().await?;
1706
1707 let engine = match ast_engine {
1708 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1709 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1710 };
1711
1712 if let Either::Right(resp) = session.check_relation_name_duplicated(
1713 table_name.clone(),
1714 StatementType::CREATE_TABLE,
1715 if_not_exists,
1716 )? {
1717 return Ok(resp);
1718 }
1719
1720 let (graph, source, hummock_table, job_type, shared_source_id) = {
1721 let (plan, source, table, job_type, shared_source_id) = Box::pin(handle_create_table_plan(
1722 handler_args.clone(),
1723 ExplainOptions::default(),
1724 format_encode,
1725 cdc_table_info,
1726 &table_name,
1727 column_defs.clone(),
1728 wildcard_idx,
1729 constraints.clone(),
1730 source_watermarks,
1731 append_only,
1732 on_conflict,
1733 with_version_columns,
1734 include_column_options,
1735 webhook_info,
1736 engine,
1737 ))
1738 .await?;
1739 tracing::trace!("table_plan: {:?}", plan.explain_to_string());
1740
1741 let graph = build_graph(plan, Some(GraphJobType::Table))?;
1742
1743 (graph, source, table, job_type, shared_source_id)
1744 };
1745
1746 tracing::trace!(
1747 "name={}, graph=\n{}",
1748 table_name,
1749 serde_json::to_string_pretty(&graph).unwrap()
1750 );
1751
1752 let dependencies = shared_source_id
1753 .map(|id| HashSet::from([id.as_object_id()]))
1754 .unwrap_or_default();
1755
1756 match engine {
1758 Engine::Hummock => {
1759 let catalog_writer = session.catalog_writer()?;
1760 let action = match job_type {
1761 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1762 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1763 };
1764 execute_with_long_running_notification(
1765 catalog_writer.create_table(
1766 source.map(|s| s.to_prost()),
1767 hummock_table.to_prost(),
1768 graph,
1769 job_type,
1770 if_not_exists,
1771 dependencies,
1772 ),
1773 &session,
1774 "CREATE TABLE",
1775 action,
1776 )
1777 .await?;
1778 }
1779 Engine::Iceberg => {
1780 let hummock_table_name = hummock_table.name.clone();
1781 session.create_staging_table(hummock_table.clone());
1782 let res = Box::pin(create_iceberg_engine_table(
1783 session.clone(),
1784 handler_args,
1785 source.map(|s| s.to_prost()),
1786 hummock_table,
1787 graph,
1788 table_name,
1789 job_type,
1790 if_not_exists,
1791 ))
1792 .await;
1793 session.drop_staging_table(&hummock_table_name);
1794 res?
1795 }
1796 }
1797
1798 Ok(PgResponse::empty_result(StatementType::CREATE_TABLE))
1799}
1800
1801fn build_iceberg_engine_sink_options(
1802 mut sink_options: BTreeMap<String, String>,
1803 user_options: &WithOptions,
1804 table: &TableCatalog,
1805 primary_key: &[String],
1806) -> Result<BTreeMap<String, String>> {
1807 sink_options.extend(
1808 user_options
1809 .iter()
1810 .filter(|(key, _)| is_iceberg_engine_option(key))
1811 .map(|(key, value)| (key.clone(), value.clone())),
1812 );
1813
1814 sink_options
1815 .entry(ENABLE_COMPACTION.to_owned())
1816 .or_insert_with(|| "true".to_owned());
1817 sink_options.insert(
1818 "type".to_owned(),
1819 if table.append_only {
1820 "append-only"
1821 } else {
1822 "upsert"
1823 }
1824 .to_owned(),
1825 );
1826
1827 if !table.append_only {
1830 sink_options.insert("primary_key".to_owned(), primary_key.join(","));
1831 }
1832
1833 sink_options.insert("create_table_if_not_exists".to_owned(), "true".to_owned());
1834 sink_options.insert("is_exactly_once".to_owned(), "true".to_owned());
1835
1836 let config = IcebergConfig::from_btreemap(sink_options.clone())?;
1837
1838 if config.table_format_version() < FormatVersion::V3 {
1841 sink_options
1842 .entry(ENABLE_MANIFEST_REWRITE.to_owned())
1843 .or_insert_with(|| "true".to_owned());
1844 }
1845
1846 if let Some(partition_by) = &config.partition_by {
1847 let mut partition_columns = vec![];
1848 for (column, _) in parse_partition_by_exprs(partition_by.clone())? {
1849 table
1850 .columns()
1851 .iter()
1852 .find(|col| col.name().eq_ignore_ascii_case(&column))
1853 .ok_or_else(|| {
1854 ErrorCode::InvalidInputSyntax(format!(
1855 "Partition source column does not exist in schema: {}",
1856 column
1857 ))
1858 })?;
1859
1860 partition_columns.push(column);
1861 }
1862
1863 ensure_partition_columns_are_prefix_of_primary_key(&partition_columns, primary_key)
1864 .map_err(|_| {
1865 ErrorCode::InvalidInputSyntax(
1866 "The partition columns should be the prefix of the primary key".to_owned(),
1867 )
1868 })?;
1869 }
1870
1871 if let Some(order_key) = &config.order_key {
1872 validate_order_key_columns(order_key, table.columns().iter().map(|col| col.name()))
1873 .map_err(|err| ErrorCode::InvalidInputSyntax(err.to_report_string()))?;
1874 }
1875
1876 if config.enable_pk_index {
1877 sink_options.remove("primary_key");
1878 } else {
1879 sink_options.insert(AUTO_SCHEMA_CHANGE_KEY.to_owned(), "true".to_owned());
1880 }
1881
1882 Ok(sink_options)
1883}
1884
1885#[allow(clippy::too_many_arguments)]
1894pub async fn create_iceberg_engine_table(
1895 session: Arc<SessionImpl>,
1896 handler_args: HandlerArgs,
1897 mut source: Option<PbSource>,
1898 table: TableCatalog,
1899 graph: StreamFragmentGraph,
1900 table_name: ObjectName,
1901 job_type: PbTableJobType,
1902 if_not_exists: bool,
1903) -> Result<()> {
1904 let rw_db_name = session
1905 .env()
1906 .catalog_reader()
1907 .read_guard()
1908 .get_database_by_id(table.database_id)?
1909 .name()
1910 .to_owned();
1911 let rw_schema_name = session
1912 .env()
1913 .catalog_reader()
1914 .read_guard()
1915 .get_schema_by_id(table.database_id, table.schema_id)?
1916 .name()
1917 .clone();
1918 let iceberg_catalog_name = rw_db_name.clone();
1919 let iceberg_database_name = rw_schema_name.clone();
1920 let iceberg_table_name = table_name.0.last().unwrap().real_value();
1921
1922 let iceberg_engine_connection: String = session.config().iceberg_engine_connection();
1923 let sink_decouple = session.config().sink_decouple();
1924 if matches!(sink_decouple, SinkDecouple::Disable) {
1925 bail!(
1926 "Iceberg engine table only supports with sink decouple, try `set sink_decouple = true` to resolve it"
1927 );
1928 }
1929
1930 let mut connection_ref = BTreeMap::new();
1931 let with_common = if iceberg_engine_connection.is_empty() {
1932 bail!("to use iceberg engine table, the variable `iceberg_engine_connection` must be set.");
1933 } else {
1934 let parts: Vec<&str> = iceberg_engine_connection.split('.').collect();
1935 assert_eq!(parts.len(), 2);
1936 let connection_catalog =
1937 session.get_connection_by_name(Some(parts[0].to_owned()), parts[1])?;
1938 if let ConnectionInfo::ConnectionParams(params) = &connection_catalog.info {
1939 if params.connection_type == ConnectionType::Iceberg as i32 {
1940 connection_ref.insert(
1942 "connection".to_owned(),
1943 ConnectionRefValue {
1944 connection_name: ObjectName::from(vec![
1945 Ident::from(parts[0]),
1946 Ident::from(parts[1]),
1947 ]),
1948 },
1949 );
1950
1951 let mut with_common = BTreeMap::new();
1952 with_common.insert("connector".to_owned(), "iceberg".to_owned());
1953 with_common.insert("database.name".to_owned(), iceberg_database_name);
1954 with_common.insert("table.name".to_owned(), iceberg_table_name);
1955
1956 let hosted_catalog = params
1957 .properties
1958 .get("hosted_catalog")
1959 .map(|s| s.eq_ignore_ascii_case("true"))
1960 .unwrap_or(false);
1961 if hosted_catalog {
1962 let meta_client = session.env().meta_client();
1963 let meta_store_endpoint = meta_client.get_meta_store_endpoint().await?;
1964
1965 let meta_store_endpoint =
1966 url::Url::parse(&meta_store_endpoint).map_err(|_| {
1967 ErrorCode::InternalError(
1968 "failed to parse the meta store endpoint".to_owned(),
1969 )
1970 })?;
1971 let meta_store_backend = meta_store_endpoint.scheme().to_owned();
1972 let meta_store_user = meta_store_endpoint.username().to_owned();
1973 let meta_store_password = match meta_store_endpoint.password() {
1974 Some(password) => percent_decode_str(password)
1975 .decode_utf8()
1976 .map_err(|_| {
1977 ErrorCode::InternalError(
1978 "failed to parse password from meta store endpoint".to_owned(),
1979 )
1980 })?
1981 .into_owned(),
1982 None => "".to_owned(),
1983 };
1984 let meta_store_host = meta_store_endpoint
1985 .host_str()
1986 .ok_or_else(|| {
1987 ErrorCode::InternalError(
1988 "failed to parse host from meta store endpoint".to_owned(),
1989 )
1990 })?
1991 .to_owned();
1992 let meta_store_port = meta_store_endpoint.port().ok_or_else(|| {
1993 ErrorCode::InternalError(
1994 "failed to parse port from meta store endpoint".to_owned(),
1995 )
1996 })?;
1997 let meta_store_database = meta_store_endpoint
1998 .path()
1999 .trim_start_matches('/')
2000 .to_owned();
2001
2002 let Ok(meta_backend) = MetaBackend::from_str(&meta_store_backend, true) else {
2003 bail!("failed to parse meta backend: {}", meta_store_backend);
2004 };
2005
2006 let catalog_uri = match meta_backend {
2007 MetaBackend::Postgres => {
2008 format!(
2009 "jdbc:postgresql://{}:{}/{}",
2010 meta_store_host, meta_store_port, meta_store_database
2011 )
2012 }
2013 MetaBackend::Mysql => {
2014 format!(
2015 "jdbc:mysql://{}:{}/{}",
2016 meta_store_host, meta_store_port, meta_store_database
2017 )
2018 }
2019 MetaBackend::Sqlite | MetaBackend::Sql | MetaBackend::Mem => {
2020 bail!(
2021 "Unsupported meta backend for iceberg engine table: {}",
2022 meta_store_backend
2023 );
2024 }
2025 };
2026
2027 with_common.insert("catalog.type".to_owned(), "jdbc".to_owned());
2028 with_common.insert("catalog.uri".to_owned(), catalog_uri);
2029 with_common.insert("catalog.jdbc.user".to_owned(), meta_store_user);
2030 with_common.insert("catalog.jdbc.password".to_owned(), meta_store_password);
2031 with_common.insert("catalog.name".to_owned(), iceberg_catalog_name);
2032 }
2033
2034 with_common
2035 } else {
2036 return Err(RwError::from(ErrorCode::InvalidParameterValue(
2037 "Only iceberg connection could be used in iceberg engine".to_owned(),
2038 )));
2039 }
2040 } else {
2041 return Err(RwError::from(ErrorCode::InvalidParameterValue(
2042 "Private Link Service has been deprecated. Please create a new connection instead."
2043 .to_owned(),
2044 )));
2045 }
2046 };
2047
2048 let mut pks = table
2051 .pk_column_names()
2052 .iter()
2053 .map(|c| c.to_string())
2054 .collect::<Vec<String>>();
2055
2056 if pks.len() == 1 && pks[0].eq(ROW_ID_COLUMN_NAME) {
2058 pks = vec![RISINGWAVE_ICEBERG_ROW_ID.to_owned()];
2059 }
2060
2061 let sink_from = CreateSink::From(table_name.clone());
2062
2063 let mut sink_name = table_name.clone();
2064 *sink_name.0.last_mut().unwrap() = Ident::from(
2065 (ICEBERG_SINK_PREFIX.to_owned() + &sink_name.0.last().unwrap().real_value()).as_str(),
2066 );
2067 let create_sink_stmt = CreateSinkStatement {
2068 or_replace: false,
2069 if_not_exists: false,
2070 sink_name,
2071 with_properties: WithProperties(vec![]),
2072 sink_from,
2073 columns: vec![],
2074 emit_mode: None,
2075 sink_schema: None,
2076 into_table_name: None,
2077 };
2078
2079 let mut sink_handler_args = handler_args.clone();
2080
2081 let sink_with = build_iceberg_engine_sink_options(
2082 with_common.clone(),
2083 &handler_args.with_options,
2084 &table,
2085 &pks,
2086 )?;
2087
2088 if let Some(source) = source.as_mut() {
2089 source
2090 .with_properties
2091 .retain(|key, _| !is_iceberg_engine_option(key));
2092 }
2093
2094 sink_handler_args.with_options =
2114 WithOptions::new(sink_with, Default::default(), connection_ref.clone());
2115 let SinkPlanContext {
2116 sink_plan,
2117 sink_catalog,
2118 ..
2119 } = gen_sink_plan(sink_handler_args, create_sink_stmt, None, true).await?;
2120 let sink_graph = build_graph(sink_plan, Some(GraphJobType::Sink))?;
2121
2122 let mut source_name = table_name.clone();
2123 *source_name.0.last_mut().unwrap() = Ident::from(
2124 (ICEBERG_SOURCE_PREFIX.to_owned() + &source_name.0.last().unwrap().real_value()).as_str(),
2125 );
2126 let create_source_stmt = CreateSourceStatement {
2127 temporary: false,
2128 if_not_exists: false,
2129 columns: vec![],
2130 source_name,
2131 wildcard_idx: Some(0),
2132 constraints: vec![],
2133 with_properties: WithProperties(vec![]),
2134 format_encode: CompatibleFormatEncode::V2(FormatEncodeOptions::none()),
2135 source_watermarks: vec![],
2136 include_column_options: vec![],
2137 };
2138
2139 let mut source_handler_args = handler_args.clone();
2140 let source_with = with_common;
2141 source_handler_args.with_options =
2142 WithOptions::new(source_with, Default::default(), connection_ref);
2143
2144 let overwrite_options = OverwriteOptions::new(&mut source_handler_args);
2145 let format_encode = create_source_stmt.format_encode.into_v2_with_warning();
2146 let (with_properties, refresh_mode) =
2147 bind_connector_props(&source_handler_args, &format_encode, true)?;
2148
2149 let (iceberg_catalog, table_identifier, table_created) = {
2152 let sink_param = SinkParam::try_from_sink_catalog(sink_catalog.clone())?;
2153 let iceberg_sink = IcebergSink::try_from(sink_param)?;
2154 let table_created = iceberg_sink.create_table_if_not_exists().await?;
2155
2156 let iceberg_catalog = iceberg_sink.config.create_catalog().await?;
2157 let table_identifier = iceberg_sink.config.full_table_name()?;
2158 (iceberg_catalog, table_identifier, table_created)
2159 };
2160
2161 let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
2162 let (columns_from_resolve_source, source_info) = bind_columns_from_source(
2163 &session,
2164 &format_encode,
2165 Either::Left(&with_properties),
2166 create_source_type,
2167 )
2168 .await?;
2169 let mut col_id_gen = ColumnIdGenerator::new_initial();
2170
2171 let iceberg_source_catalog = bind_create_source_or_table_with_connector(
2172 source_handler_args,
2173 create_source_stmt.source_name,
2174 format_encode,
2175 with_properties,
2176 &create_source_stmt.columns,
2177 create_source_stmt.constraints,
2178 create_source_stmt.wildcard_idx,
2179 create_source_stmt.source_watermarks,
2180 columns_from_resolve_source,
2181 source_info,
2182 create_source_stmt.include_column_options,
2183 &mut col_id_gen,
2184 create_source_type,
2185 overwrite_options.source_rate_limit,
2186 SqlColumnStrategy::FollowChecked,
2187 refresh_mode,
2188 )
2189 .await?;
2190
2191 let _ = Jvm::get_or_init()?;
2194
2195 let catalog_writer = session.catalog_writer()?;
2196 let action = match job_type {
2197 TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
2198 _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
2199 };
2200 let res = execute_with_long_running_notification(
2201 catalog_writer.create_iceberg_table(
2202 PbTableJobInfo {
2203 source,
2204 table: Some(table.to_prost()),
2205 fragment_graph: Some(graph),
2206 job_type: job_type as _,
2207 },
2208 PbSinkJobInfo {
2209 sink: Some(sink_catalog.to_proto()),
2210 fragment_graph: Some(sink_graph),
2211 },
2212 iceberg_source_catalog.to_prost(),
2213 if_not_exists,
2214 ),
2215 &session,
2216 "CREATE TABLE",
2217 action,
2218 )
2219 .await;
2220
2221 if res.is_err() {
2222 if table_created {
2225 let _ = iceberg_catalog
2226 .drop_table(&table_identifier)
2227 .await
2228 .inspect_err(|err| {
2229 tracing::error!(
2230 "failed to drop iceberg table {} after create iceberg engine table failed: {}",
2231 table_identifier,
2232 err.as_report()
2233 );
2234 });
2235 }
2236 res?
2237 }
2238
2239 Ok(())
2240}
2241
2242pub fn check_create_table_with_source(
2243 with_options: &WithOptions,
2244 format_encode: Option<FormatEncodeOptions>,
2245 include_column_options: &IncludeOption,
2246 cdc_table_info: &Option<CdcTableInfo>,
2247) -> Result<Option<FormatEncodeOptions>> {
2248 if cdc_table_info.is_some() {
2250 return Ok(format_encode);
2251 }
2252 let defined_source = with_options.is_source_connector();
2253
2254 if !include_column_options.is_empty() && !defined_source {
2255 return Err(ErrorCode::InvalidInputSyntax(
2256 "INCLUDE should be used with a connector".to_owned(),
2257 )
2258 .into());
2259 }
2260 if defined_source {
2261 format_encode.as_ref().ok_or_else(|| {
2262 ErrorCode::InvalidInputSyntax("Please specify a source schema using FORMAT".to_owned())
2263 })?;
2264 }
2265 Ok(format_encode)
2266}
2267
2268fn ensure_partition_columns_are_prefix_of_primary_key(
2269 partition_columns: &[String],
2270 primary_key_columns: &[String],
2271) -> std::result::Result<(), String> {
2272 if partition_columns.len() > primary_key_columns.len() {
2273 return Err("Partition columns cannot be longer than primary key columns.".to_owned());
2274 }
2275
2276 for (i, partition_col) in partition_columns.iter().enumerate() {
2277 if primary_key_columns.get(i) != Some(partition_col) {
2278 return Err(format!(
2279 "Partition column '{}' is not a prefix of the primary key.",
2280 partition_col
2281 ));
2282 }
2283 }
2284
2285 Ok(())
2286}
2287
2288#[allow(clippy::too_many_arguments)]
2289pub async fn generate_stream_graph_for_replace_table(
2290 _session: &Arc<SessionImpl>,
2291 table_name: ObjectName,
2292 original_catalog: &Arc<TableCatalog>,
2293 handler_args: HandlerArgs,
2294 statement: Statement,
2295 col_id_gen: ColumnIdGenerator,
2296 sql_column_strategy: SqlColumnStrategy,
2297) -> Result<(
2298 StreamFragmentGraph,
2299 TableCatalog,
2300 Option<SourceCatalog>,
2301 TableJobType,
2302)> {
2303 let Statement::CreateTable {
2304 columns,
2305 constraints,
2306 source_watermarks,
2307 append_only,
2308 on_conflict,
2309 with_version_columns,
2310 wildcard_idx,
2311 cdc_table_info,
2312 format_encode,
2313 include_column_options,
2314 engine,
2315 with_options,
2316 ..
2317 } = statement
2318 else {
2319 panic!("unexpected statement type: {:?}", statement);
2320 };
2321
2322 let format_encode = format_encode
2323 .clone()
2324 .map(|format_encode| format_encode.into_v2_with_warning());
2325
2326 let engine = match engine {
2327 risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
2328 risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
2329 };
2330
2331 let is_drop_connector =
2332 original_catalog.associated_source_id().is_some() && format_encode.is_none();
2333 if is_drop_connector {
2334 debug_assert!(
2335 source_watermarks.is_empty()
2336 && include_column_options.is_empty()
2337 && with_options
2338 .iter()
2339 .all(|opt| opt.name.real_value().to_lowercase() != "connector")
2340 );
2341 }
2342
2343 let props = CreateTableProps {
2344 definition: handler_args.normalized_sql.clone(),
2345 append_only,
2346 on_conflict: on_conflict.into(),
2347 with_version_columns: with_version_columns
2348 .iter()
2349 .map(|col| col.real_value())
2350 .collect(),
2351 webhook_info: original_catalog.webhook_info.clone(),
2352 engine,
2353 };
2354
2355 let ((plan, mut source, mut table), job_type) = match (format_encode, cdc_table_info.as_ref()) {
2356 (Some(format_encode), None) => (
2357 gen_create_table_plan_with_source(
2358 handler_args,
2359 ExplainOptions::default(),
2360 table_name,
2361 columns,
2362 wildcard_idx,
2363 constraints,
2364 format_encode,
2365 source_watermarks,
2366 col_id_gen,
2367 include_column_options,
2368 props,
2369 sql_column_strategy,
2370 )
2371 .await?,
2372 TableJobType::General,
2373 ),
2374 (None, None) => {
2375 let context = OptimizerContext::from_handler_args(handler_args);
2376 let (plan, table) = gen_create_table_plan(
2377 context,
2378 table_name,
2379 columns,
2380 constraints,
2381 col_id_gen,
2382 source_watermarks,
2383 props,
2384 true,
2385 )?;
2386 ((plan, None, table), TableJobType::General)
2387 }
2388 (None, Some(cdc_table)) => {
2389 sanity_check_for_table_on_cdc_source(
2390 append_only,
2391 &columns,
2392 &wildcard_idx,
2393 &constraints,
2394 &source_watermarks,
2395 )?;
2396
2397 let session = &handler_args.session;
2398 let (source, resolved_table_name) =
2399 get_source_and_resolved_table_name(session, cdc_table.clone(), table_name.clone())?;
2400
2401 let (cdc_with_options, normalized_external_table_name) =
2402 derive_with_options_for_cdc_table(
2403 &source.with_properties,
2404 cdc_table.external_table_name.clone(),
2405 )?;
2406
2407 let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
2408 let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
2409 cdc_with_options.clone(),
2410 &pk_names,
2411 ))
2412 .await?;
2413
2414 reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
2417
2418 let context: OptimizerContextRef =
2419 OptimizerContext::new(handler_args, ExplainOptions::default()).into();
2420 let (plan, table) = gen_create_table_plan_for_cdc_table(
2421 context,
2422 source,
2423 normalized_external_table_name,
2424 columns,
2425 source_watermarks,
2426 column_catalogs,
2427 pk_names,
2428 pk_comparisons,
2429 cdc_with_options,
2430 col_id_gen,
2431 on_conflict,
2432 with_version_columns
2433 .iter()
2434 .map(|col| col.real_value())
2435 .collect(),
2436 include_column_options,
2437 table_name,
2438 resolved_table_name,
2439 original_catalog.database_id,
2440 original_catalog.schema_id,
2441 original_catalog.id(),
2442 engine,
2443 )?;
2444
2445 ((plan, None, table), TableJobType::SharedCdcSource)
2446 }
2447 (Some(_), Some(_)) => {
2448 return Err(ErrorCode::NotSupported(
2449 "Data format and encoding format doesn't apply to table created from a CDC source"
2450 .into(),
2451 "Remove the FORMAT and ENCODE specification".into(),
2452 )
2453 .into());
2454 }
2455 };
2456
2457 if table.pk_column_ids() != original_catalog.pk_column_ids() {
2458 Err(ErrorCode::InvalidInputSyntax(
2459 "alter primary key of table is not supported".to_owned(),
2460 ))?
2461 }
2462
2463 let graph = build_graph(plan, Some(GraphJobType::Table))?;
2464
2465 table.id = original_catalog.id();
2467 if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2468 table.associated_source_id = Some(source_id);
2469
2470 let source = source.as_mut().unwrap();
2471 source.id = source_id;
2472 source.associated_table_id = Some(table.id());
2473 }
2474
2475 Ok((graph, table, source, job_type))
2476}
2477
2478fn get_source_and_resolved_table_name(
2479 session: &Arc<SessionImpl>,
2480 cdc_table: CdcTableInfo,
2481 table_name: ObjectName,
2482) -> Result<(Arc<SourceCatalog>, String)> {
2483 let db_name = &session.database();
2484 let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2485
2486 let (source_schema, source_name) =
2487 Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2488
2489 let source = {
2490 let catalog_reader = session.env().catalog_reader().read_guard();
2491 let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2492 let (source, _) = catalog_reader.get_source_by_name(
2493 db_name,
2494 SchemaPath::Name(schema_name.as_str()),
2495 source_name.as_str(),
2496 )?;
2497 source.clone()
2498 };
2499 check_cdc_source_select_privilege(session, &source)?;
2500
2501 Ok((source, resolved_table_name))
2502}
2503
2504fn check_cdc_source_select_privilege(session: &SessionImpl, source: &SourceCatalog) -> Result<()> {
2505 session.check_privileges(&[ObjectCheckItem::new(
2506 source.owner,
2507 AclMode::Select,
2508 source.name.clone(),
2509 source.id,
2510 )])
2511}
2512
2513fn bind_webhook_info(
2515 session: &Arc<SessionImpl>,
2516 column_defs: &[ColumnDef],
2517 webhook_info: WebhookSourceInfo,
2518) -> Result<PbWebhookSourceInfo> {
2519 let WebhookSourceInfo {
2520 secret_ref,
2521 signature_expr,
2522 wait_for_persistence,
2523 is_batched,
2524 } = webhook_info;
2525
2526 for column in column_defs {
2527 for option_def in &column.options {
2528 match option_def.option {
2529 ColumnOption::Null => {}
2530 ColumnOption::GeneratedColumns(_) => {
2531 return Err(ErrorCode::InvalidInputSyntax(
2532 "generated columns are not supported for webhook tables".to_owned(),
2533 )
2534 .into());
2535 }
2536 ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2537 return Err(ErrorCode::InvalidInputSyntax(
2538 "default values are not supported for webhook tables".to_owned(),
2539 )
2540 .into());
2541 }
2542 ColumnOption::NotNull
2543 | ColumnOption::Unique { .. }
2544 | ColumnOption::ForeignKey { .. }
2545 | ColumnOption::Check(_)
2546 | ColumnOption::DialectSpecific(_) => {
2547 return Err(ErrorCode::InvalidInputSyntax(
2548 "only NULL column option is supported for webhook tables".to_owned(),
2549 )
2550 .into());
2551 }
2552 }
2553 }
2554 }
2555
2556 let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2558 let db_name = &session.database();
2559 let (schema_name, secret_name) =
2560 Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2561 let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2562 (
2563 Some(PbSecretRef {
2564 secret_id: secret_catalog.id,
2565 ref_as: match secret_ref.ref_as {
2566 SecretRefAsType::Text => PbRefAsType::Text,
2567 SecretRefAsType::File => PbRefAsType::File,
2568 }
2569 .into(),
2570 }),
2571 Some(secret_name),
2572 )
2573 } else {
2574 (None, None)
2575 };
2576
2577 let signature_expr = if let Some(signature_expr) = signature_expr {
2578 let payload_name = if column_defs.len() == 1
2579 && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
2580 {
2581 column_defs[0].name.real_value()
2582 } else {
2583 WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
2584 };
2585 let secure_compare_context = SecureCompareContext {
2586 payload_name,
2587 secret_name,
2588 };
2589 let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
2590 let expr = binder.bind_expr(&signature_expr)?;
2591
2592 if expr.as_function_call().is_none()
2594 || expr.as_function_call().unwrap().func_type()
2595 != crate::optimizer::plan_node::generic::ExprType::SecureCompare
2596 {
2597 return Err(ErrorCode::InvalidInputSyntax(
2598 "The signature verification function must be SECURE_COMPARE()".to_owned(),
2599 )
2600 .into());
2601 }
2602
2603 Some(expr.to_expr_proto())
2604 } else {
2605 session.notice_to_user(
2606 "VALIDATE clause is strongly recommended for safety or production usages",
2607 );
2608 None
2609 };
2610
2611 let pb_webhook_info = PbWebhookSourceInfo {
2612 secret_ref: pb_secret_ref,
2613 signature_expr,
2614 wait_for_persistence,
2615 is_batched,
2616 };
2617
2618 Ok(pb_webhook_info)
2619}
2620
2621#[cfg(test)]
2622mod tests {
2623 use risingwave_common::catalog::{
2624 DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
2625 };
2626 use risingwave_common::types::{DataType, StructType};
2627
2628 use super::*;
2629 use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
2630
2631 fn test_schema_table_name() -> SchemaTableName {
2632 SchemaTableName {
2633 schema_name: "public".to_owned(),
2634 table_name: "orders".to_owned(),
2635 }
2636 }
2637
2638 fn pk_names() -> Vec<String> {
2639 vec!["plan_id".to_owned(), "site_id".to_owned()]
2640 }
2641
2642 #[tokio::test]
2643 async fn test_cdc_table_requires_select_privilege_on_source() {
2644 let frontend = LocalFrontend::new(Default::default()).await;
2645 frontend
2648 .run_sql(
2649 r#"
2650 CREATE SOURCE cdc_source WITH (
2651 connector = 'postgres-cdc',
2652 hostname = 'localhost',
2653 port = '5432',
2654 username = 'postgres',
2655 password = '',
2656 database.name = 'db'
2657 ) FORMAT PLAIN ENCODE JSON
2658 "#,
2659 )
2660 .await
2661 .unwrap();
2662 frontend.run_sql("CREATE USER cdc_user").await.unwrap();
2663 frontend
2664 .run_sql("GRANT CREATE ON SCHEMA public TO cdc_user")
2665 .await
2666 .unwrap();
2667
2668 let user_id = frontend
2669 .session_ref()
2670 .env()
2671 .user_info_reader()
2672 .read_guard()
2673 .get_user_by_name("cdc_user")
2674 .unwrap()
2675 .id;
2676 let user_session = frontend.session_user_ref(
2677 DEFAULT_DATABASE_NAME.to_owned(),
2678 "cdc_user".to_owned(),
2679 user_id,
2680 );
2681 let create_table =
2682 "CREATE TABLE cdc_table (id INT PRIMARY KEY) FROM cdc_source TABLE 'public.t'";
2683
2684 let err = frontend
2685 .run_sql_with_session(user_session.clone(), create_table)
2686 .await
2687 .unwrap_err();
2688 assert!(
2689 err.to_string()
2690 .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2691 "{err:?}"
2692 );
2693
2694 frontend
2695 .run_sql("GRANT SELECT ON SOURCE cdc_source TO cdc_user")
2696 .await
2697 .unwrap();
2698 frontend
2699 .run_sql_with_session(user_session.clone(), create_table)
2700 .await
2701 .unwrap();
2702
2703 frontend
2704 .run_sql("REVOKE SELECT ON SOURCE cdc_source FROM cdc_user")
2705 .await
2706 .unwrap();
2707 let err = frontend
2708 .run_sql_with_session(user_session, "ALTER TABLE cdc_table ADD COLUMN value INT")
2709 .await
2710 .unwrap_err();
2711 assert!(
2712 err.to_string()
2713 .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2714 "{err:?}"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_debezium_filter_rejects_literal_excluded_pk() {
2720 let err = reject_pk_filtered_by_debezium_column_filter_inner(
2721 &pk_names(),
2722 &test_schema_table_name(),
2723 Some("public.orders.site_id"),
2724 None,
2725 )
2726 .unwrap_err();
2727
2728 assert!(err.to_report_string().contains("site_id"));
2729 assert!(
2730 err.to_report_string()
2731 .contains("debezium.column.exclude.list")
2732 );
2733 }
2734
2735 #[test]
2736 fn test_debezium_filter_rejects_include_list_missing_pk() {
2737 let err = reject_pk_filtered_by_debezium_column_filter_inner(
2738 &pk_names(),
2739 &test_schema_table_name(),
2740 None,
2741 Some("public.orders.plan_id,public.orders.payload"),
2742 )
2743 .unwrap_err();
2744
2745 assert!(err.to_report_string().contains("site_id"));
2746 assert!(
2747 err.to_report_string()
2748 .contains("debezium.column.include.list")
2749 );
2750 }
2751
2752 #[test]
2753 fn test_debezium_filter_accepts_include_list_covering_all_pks() {
2754 reject_pk_filtered_by_debezium_column_filter_inner(
2755 &pk_names(),
2756 &test_schema_table_name(),
2757 None,
2758 Some("public.orders.plan_id,public.orders.site_id,public.orders.payload"),
2759 )
2760 .unwrap();
2761 }
2762
2763 #[test]
2764 fn test_debezium_filter_matches_regex_patterns() {
2765 reject_pk_filtered_by_debezium_column_filter_inner(
2766 &pk_names(),
2767 &test_schema_table_name(),
2768 None,
2769 Some(r"public[.]orders[.](plan_id|site_id),public[.]orders[.]payload"),
2770 )
2771 .unwrap();
2772
2773 let err = reject_pk_filtered_by_debezium_column_filter_inner(
2774 &pk_names(),
2775 &test_schema_table_name(),
2776 Some(r".*[.]orders[.]site_id"),
2777 None,
2778 )
2779 .unwrap_err();
2780
2781 assert!(err.to_report_string().contains("site_id"));
2782 }
2783
2784 #[test]
2785 fn test_debezium_filter_matches_patterns_case_insensitively() {
2786 let err = reject_pk_filtered_by_debezium_column_filter_inner(
2787 &pk_names(),
2788 &test_schema_table_name(),
2789 Some("PUBLIC.ORDERS.SITE_ID"),
2790 None,
2791 )
2792 .unwrap_err();
2793
2794 assert!(err.to_report_string().contains("site_id"));
2795
2796 reject_pk_filtered_by_debezium_column_filter_inner(
2797 &pk_names(),
2798 &test_schema_table_name(),
2799 None,
2800 Some("Public.Orders.Plan_ID,Public.Orders.Site_ID"),
2801 )
2802 .unwrap();
2803 }
2804
2805 #[tokio::test]
2806 async fn test_create_table_handler() {
2807 let sql =
2808 "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
2809 let frontend = LocalFrontend::new(Default::default()).await;
2810 frontend.run_sql(sql).await.unwrap();
2811
2812 let session = frontend.session_ref();
2813 let catalog_reader = session.env().catalog_reader().read_guard();
2814 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2815
2816 let (table, _) = catalog_reader
2818 .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2819 .unwrap();
2820 assert_eq!(table.name(), "t");
2821
2822 let columns = table
2823 .columns
2824 .iter()
2825 .map(|col| (col.name(), col.data_type().clone()))
2826 .collect::<HashMap<&str, DataType>>();
2827
2828 let expected_columns = maplit::hashmap! {
2829 ROW_ID_COLUMN_NAME => DataType::Serial,
2830 "v1" => DataType::Int16,
2831 "v2" => StructType::new(
2832 vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
2833 )
2834 .with_ids([3, 4, 5].map(ColumnId::new))
2835 .into(),
2836 RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
2837 };
2838
2839 assert_eq!(columns, expected_columns, "{columns:#?}");
2840 }
2841
2842 #[tokio::test]
2843 async fn test_create_webhook_table_with_arbitrary_columns() {
2844 let frontend = LocalFrontend::new(Default::default()).await;
2845 frontend
2846 .run_sql("create schema ingest_schema;")
2847 .await
2848 .unwrap();
2849 frontend
2850 .run_sql(
2851 r#"
2852 create table ingest_schema.orders (
2853 id int,
2854 customer_name varchar,
2855 amount double precision,
2856 primary key (id)
2857 ) with (
2858 connector = 'webhook'
2859 ) validate as secure_compare(
2860 headers->>'x-rw-signature',
2861 'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
2862 );
2863 "#,
2864 )
2865 .await
2866 .unwrap();
2867
2868 let session = frontend.session_ref();
2869 let catalog_reader = session.env().catalog_reader().read_guard();
2870 let (table, _) = catalog_reader
2871 .get_created_table_by_name(
2872 DEFAULT_DATABASE_NAME,
2873 SchemaPath::Name("ingest_schema"),
2874 "orders",
2875 )
2876 .unwrap();
2877
2878 assert!(table.webhook_info.is_some());
2879 assert_eq!(
2880 table
2881 .columns
2882 .iter()
2883 .filter(|column| column.can_dml())
2884 .count(),
2885 3
2886 );
2887 }
2888
2889 #[tokio::test]
2890 async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
2891 let frontend = LocalFrontend::new(Default::default()).await;
2892 frontend
2893 .run_sql(
2894 r#"
2895 create table webhook_single_column (
2896 body jsonb
2897 ) with (
2898 connector = 'webhook'
2899 ) validate as secure_compare(
2900 headers->>'x-rw-signature',
2901 'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
2902 );
2903 "#,
2904 )
2905 .await
2906 .unwrap();
2907 }
2908
2909 #[tokio::test]
2910 async fn test_create_webhook_table_with_generated_columns() {
2911 let frontend = LocalFrontend::new(Default::default()).await;
2912 let err = frontend
2913 .run_sql(
2914 r#"
2915 create table webhook_generated_columns (
2916 id int,
2917 amount double precision,
2918 amount_with_fee double precision as amount + 1.0
2919 ) with (
2920 connector = 'webhook'
2921 );
2922 "#,
2923 )
2924 .await
2925 .unwrap_err();
2926
2927 assert!(
2928 err.to_string()
2929 .contains("generated columns are not supported for webhook tables"),
2930 "{err:?}"
2931 );
2932 }
2933
2934 #[tokio::test]
2935 async fn test_create_webhook_table_with_default_value() {
2936 let frontend = LocalFrontend::new(Default::default()).await;
2937 let err = frontend
2938 .run_sql(
2939 r#"
2940 create table webhook_default_value (
2941 id int default 42,
2942 amount double precision
2943 ) with (
2944 connector = 'webhook'
2945 );
2946 "#,
2947 )
2948 .await
2949 .unwrap_err();
2950
2951 assert!(
2952 err.to_string()
2953 .contains("default values are not supported for webhook tables"),
2954 "{err:?}"
2955 );
2956 }
2957
2958 #[tokio::test]
2959 async fn test_create_webhook_table_with_not_null_option() {
2960 let frontend = LocalFrontend::new(Default::default()).await;
2961 let err = frontend
2962 .run_sql(
2963 r#"
2964 create table webhook_not_null (
2965 id int not null,
2966 amount double precision
2967 ) with (
2968 connector = 'webhook'
2969 );
2970 "#,
2971 )
2972 .await
2973 .unwrap_err();
2974
2975 assert!(
2976 err.to_string()
2977 .contains("only NULL column option is supported for webhook tables"),
2978 "{err:?}"
2979 );
2980 }
2981
2982 #[test]
2983 fn test_parse_postgres_cdc_external_table_name() {
2984 for (input, expected) in [
2985 ("public.Note", ("public", "Note")),
2986 ("public.\"Note\"", ("public", "Note")),
2987 (
2988 "\"Mixed.Schema\".\"Note.Table\"",
2989 ("Mixed.Schema", "Note.Table"),
2990 ),
2991 ("public.\"Note\"\"Archive\"", ("public", "Note\"Archive")),
2992 ] {
2993 assert_eq!(
2994 parse_postgres_cdc_external_table_name(input).unwrap(),
2995 (expected.0.to_owned(), expected.1.to_owned()),
2996 "input: {input}"
2997 );
2998 }
2999
3000 for input in [
3001 "Note",
3002 "public.",
3003 ".Note",
3004 "public.\"Note",
3005 "public.\"Note\"Archive",
3006 "public.Note.Archive",
3007 ] {
3008 assert!(
3009 parse_postgres_cdc_external_table_name(input).is_err(),
3010 "input should be rejected: {input}"
3011 );
3012 }
3013 }
3014
3015 #[test]
3016 fn test_bind_primary_key() {
3017 for (sql, expected) in [
3020 ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
3021 ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
3022 ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
3023 (
3024 "create table t (v1 int primary key, v2 int primary key)",
3025 Err("multiple primary keys are not allowed"),
3026 ),
3027 (
3028 "create table t (v1 int primary key primary key, v2 int)",
3029 Err("multiple primary keys are not allowed"),
3030 ),
3031 (
3032 "create table t (v1 int, v2 int, primary key (v1))",
3033 Ok(&[1]),
3034 ),
3035 (
3036 "create table t (v1 int, primary key (v2), v2 int)",
3037 Ok(&[2]),
3038 ),
3039 (
3040 "create table t (primary key (v2, v1), v1 int, v2 int)",
3041 Ok(&[2, 1]),
3042 ),
3043 (
3044 "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
3045 Err("multiple primary keys are not allowed"),
3046 ),
3047 (
3048 "create table t (v1 int primary key, primary key (v1), v2 int)",
3049 Err("multiple primary keys are not allowed"),
3050 ),
3051 (
3052 "create table t (v1 int, primary key (V3), v2 int)",
3053 Err("column \"v3\" named in key does not exist"),
3054 ),
3055 ] {
3056 let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
3057 let risingwave_sqlparser::ast::Statement::CreateTable {
3058 columns: column_defs,
3059 constraints,
3060 ..
3061 } = ast.remove(0)
3062 else {
3063 panic!("test case should be create table")
3064 };
3065 let actual: Result<_> = (|| {
3066 let mut columns = bind_sql_columns(&column_defs, false)?;
3067 let mut col_id_gen = ColumnIdGenerator::new_initial();
3068 for c in &mut columns {
3069 col_id_gen.generate(c)?;
3070 }
3071
3072 let pk_names =
3073 bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
3074 let (_, pk_column_ids, _) =
3075 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
3076 Ok(pk_column_ids)
3077 })();
3078 match (expected, actual) {
3079 (Ok(expected), Ok(actual)) => assert_eq!(
3080 expected.iter().copied().map(ColumnId::new).collect_vec(),
3081 actual,
3082 "sql: {sql}"
3083 ),
3084 (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
3085 (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
3086 (Err(expected), Err(actual)) => assert!(
3087 actual.to_string().contains(expected),
3088 "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
3089 ),
3090 }
3091 }
3092 }
3093
3094 #[tokio::test]
3095 async fn test_duplicate_props_options() {
3096 let proto_file = create_proto_file(PROTO_FILE_DATA);
3097 let sql = format!(
3098 r#"CREATE TABLE t
3099 WITH (
3100 connector = 'kinesis',
3101 aws.region='user_test_topic',
3102 endpoint='172.10.1.1:9090,172.10.1.2:9090',
3103 aws.credentials.access_key_id = 'your_access_key_1',
3104 aws.credentials.secret_access_key = 'your_secret_key_1'
3105 )
3106 FORMAT PLAIN ENCODE PROTOBUF (
3107 message = '.test.TestRecord',
3108 aws.credentials.access_key_id = 'your_access_key_2',
3109 aws.credentials.secret_access_key = 'your_secret_key_2',
3110 schema.location = 'file://{}',
3111 )"#,
3112 proto_file.path().to_str().unwrap()
3113 );
3114 let frontend = LocalFrontend::new(Default::default()).await;
3115 frontend.run_sql(sql).await.unwrap();
3116
3117 let session = frontend.session_ref();
3118 let catalog_reader = session.env().catalog_reader().read_guard();
3119 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
3120
3121 let (source, _) = catalog_reader
3123 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
3124 .unwrap();
3125 assert_eq!(source.name, "t");
3126
3127 assert_eq!(
3129 source
3130 .info
3131 .format_encode_options
3132 .get("aws.credentials.access_key_id")
3133 .unwrap(),
3134 "your_access_key_2"
3135 );
3136 assert_eq!(
3137 source
3138 .info
3139 .format_encode_options
3140 .get("aws.credentials.secret_access_key")
3141 .unwrap(),
3142 "your_secret_key_2"
3143 );
3144
3145 assert_eq!(
3147 source
3148 .with_properties
3149 .get("aws.credentials.access_key_id")
3150 .unwrap(),
3151 "your_access_key_1"
3152 );
3153 assert_eq!(
3154 source
3155 .with_properties
3156 .get("aws.credentials.secret_access_key")
3157 .unwrap(),
3158 "your_secret_key_1"
3159 );
3160
3161 assert!(!source.with_properties.contains_key("schema.location"));
3163 }
3164}