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