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