1use std::collections::{BTreeMap, HashMap, HashSet};
16use std::rc::Rc;
17use std::sync::LazyLock;
18
19use anyhow::{Context, anyhow};
20use either::Either;
21use external_schema::debezium::extract_debezium_avro_table_pk_columns;
22use external_schema::nexmark::check_nexmark_schema;
23use itertools::Itertools;
24use maplit::{convert_args, hashmap, hashset};
25use pgwire::pg_response::{PgResponse, StatementType};
26use rand::Rng;
27use risingwave_common::array::arrow::{IcebergArrowConvert, arrow_schema_iceberg};
28use risingwave_common::bail_not_implemented;
29use risingwave_common::catalog::{
30 ColumnCatalog, ColumnDesc, ColumnId, INITIAL_SOURCE_VERSION_ID, KAFKA_TIMESTAMP_COLUMN_NAME,
31 ROW_ID_COLUMN_NAME, TableId, debug_assert_column_ids_distinct,
32};
33use risingwave_common::license::Feature;
34use risingwave_common::secret::LocalSecretManager;
35use risingwave_common::system_param::reader::SystemParamsRead;
36use risingwave_common::types::DataType;
37use risingwave_common::util::iter_util::ZipEqFast;
38use risingwave_connector::parser::additional_columns::{
39 build_additional_column_desc, get_supported_additional_columns,
40 source_add_partition_offset_cols,
41};
42use risingwave_connector::parser::{
43 AvroParserConfig, DEBEZIUM_IGNORE_KEY, DebeziumAvroParserConfig, ProtobufParserConfig,
44 SchemaLocation, SpecificParserConfig, TimestamptzHandling,
45 fetch_json_schema_and_map_to_columns,
46};
47use risingwave_connector::schema::AWS_GLUE_SCHEMA_ARN_KEY;
48use risingwave_connector::schema::schema_registry::{
49 SCHEMA_REGISTRY_BACKOFF_DURATION_KEY, SCHEMA_REGISTRY_BACKOFF_FACTOR_KEY,
50 SCHEMA_REGISTRY_CA_PEM_PATH, SCHEMA_REGISTRY_MAX_DELAY_KEY, SCHEMA_REGISTRY_PASSWORD,
51 SCHEMA_REGISTRY_RETRIES_MAX_KEY, SCHEMA_REGISTRY_USERNAME, SchemaRegistryConfig,
52 name_strategy_from_str,
53};
54use risingwave_connector::source::cdc::{
55 CDC_MONGODB_STRONG_SCHEMA_KEY, CDC_SHARING_MODE_KEY, CDC_SNAPSHOT_BACKFILL,
56 CDC_SNAPSHOT_MODE_KEY, CDC_TRANSACTIONAL_KEY, CDC_WAIT_FOR_STREAMING_START_TIMEOUT,
57 CITUS_CDC_CONNECTOR, MONGODB_CDC_CONNECTOR, MYSQL_CDC_CONNECTOR, POSTGRES_CDC_CONNECTOR,
58 SQL_SERVER_CDC_CONNECTOR,
59};
60use risingwave_connector::source::datagen::DATAGEN_CONNECTOR;
61use risingwave_connector::source::iceberg::ICEBERG_CONNECTOR;
62use risingwave_connector::source::nexmark::source::{EventType, get_event_data_types_with_names};
63use risingwave_connector::source::test_source::TEST_CONNECTOR;
64pub use risingwave_connector::source::{
65 ADBC_SNOWFLAKE_CONNECTOR, UPSTREAM_SOURCE_KEY, WEBHOOK_CONNECTOR,
66};
67use risingwave_connector::source::{
68 AZBLOB_CONNECTOR, ConnectorProperties, GCS_CONNECTOR, GOOGLE_PUBSUB_CONNECTOR, KAFKA_CONNECTOR,
69 KINESIS_CONNECTOR, LEGACY_S3_CONNECTOR, MQTT_CONNECTOR, NATS_CONNECTOR, NEXMARK_CONNECTOR,
70 OPENDAL_S3_CONNECTOR, POSIX_FS_CONNECTOR, PULSAR_CONNECTOR,
71};
72use risingwave_connector::{AUTO_SCHEMA_CHANGE_KEY, WithPropertiesExt};
73use risingwave_pb::catalog::connection_params::PbConnectionType;
74use risingwave_pb::catalog::{PbSchemaRegistryNameStrategy, StreamSourceInfo, WatermarkDesc};
75use risingwave_pb::plan_common::additional_column::ColumnType as AdditionalColumnType;
76use risingwave_pb::plan_common::source_refresh_mode::{RefreshMode, SourceRefreshModeStreaming};
77use risingwave_pb::plan_common::{EncodeType, FormatType, SourceRefreshMode};
78use risingwave_pb::stream_plan::PbStreamFragmentGraph;
79use risingwave_pb::telemetry::TelemetryDatabaseObject;
80use risingwave_sqlparser::ast::{
81 AstString, ColumnDef, ColumnOption, CreateSourceStatement, Encode, Format, FormatEncodeOptions,
82 ObjectName, SourceWatermark, SqlOptionValue, TableConstraint, Value, get_delimiter,
83};
84use risingwave_sqlparser::parser::{IncludeOption, IncludeOptionItem};
85use thiserror_ext::AsReport;
86
87use super::RwPgResponse;
88use crate::binder::Binder;
89use crate::catalog::CatalogError;
90use crate::catalog::source_catalog::SourceCatalog;
91use crate::error::ErrorCode::{self, Deprecated, InvalidInputSyntax, NotSupported, ProtocolError};
92use crate::error::{Result, RwError};
93use crate::expr::{Expr, ExprRewriter, SessionTimezone};
94use crate::handler::HandlerArgs;
95use crate::handler::create_table::{
96 ColumnIdGenerator, bind_pk_and_row_id_on_relation, bind_sql_column_constraints,
97 bind_sql_columns, bind_sql_pk_names, bind_table_constraints,
98};
99use crate::handler::util::{
100 SourceSchemaCompatExt, check_connector_match_connection_type, ensure_connection_type_allowed,
101 ensure_local_fs_connector_allowed,
102};
103use crate::optimizer::plan_node::generic::SourceNodeKind;
104use crate::optimizer::plan_node::{BackfillType, LogicalSource, ToStream, ToStreamContext};
105use crate::session::SessionImpl;
106use crate::session::current::notice_to_user;
107use crate::utils::{
108 OverwriteOptions, resolve_connection_ref_and_secret_ref, resolve_privatelink_in_with_option,
109 resolve_secret_ref_in_with_options, resolve_source_refresh_mode_in_with_option,
110};
111use crate::{OptimizerContext, WithOptions, WithOptionsSecResolved, bind_data_type, build_graph};
112
113mod external_schema;
114pub use external_schema::{
115 bind_columns_from_source, get_schema_location, schema_has_schema_registry,
116};
117mod validate;
118pub use validate::validate_compatibility;
119use validate::{SOURCE_ALLOWED_CONNECTION_CONNECTOR, SOURCE_ALLOWED_CONNECTION_SCHEMA_REGISTRY};
120mod additional_column;
121use additional_column::check_and_add_timestamp_column;
122pub use additional_column::handle_addition_columns;
123use risingwave_common::catalog::ICEBERG_SOURCE_PREFIX;
124use risingwave_common::id::SourceId;
125
126use crate::stream_fragmenter::GraphJobType;
127
128fn non_generated_sql_columns(columns: &[ColumnDef]) -> Vec<ColumnDef> {
129 columns
130 .iter()
131 .filter(|c| !c.is_generated())
132 .cloned()
133 .collect()
134}
135
136fn try_consume_string_from_options(
137 format_encode_options: &mut BTreeMap<String, String>,
138 key: &str,
139) -> Option<AstString> {
140 format_encode_options.remove(key).map(AstString)
141}
142
143fn try_consume_schema_registry_config_from_options(
144 format_encode_options: &mut BTreeMap<String, String>,
145) {
146 [
147 SCHEMA_REGISTRY_USERNAME,
148 SCHEMA_REGISTRY_PASSWORD,
149 SCHEMA_REGISTRY_CA_PEM_PATH,
150 SCHEMA_REGISTRY_MAX_DELAY_KEY,
151 SCHEMA_REGISTRY_BACKOFF_DURATION_KEY,
152 SCHEMA_REGISTRY_BACKOFF_FACTOR_KEY,
153 SCHEMA_REGISTRY_RETRIES_MAX_KEY,
154 ]
155 .iter()
156 .for_each(|key| {
157 try_consume_string_from_options(format_encode_options, key);
158 });
159}
160
161fn consume_string_from_options(
162 format_encode_options: &mut BTreeMap<String, String>,
163 key: &str,
164) -> Result<AstString> {
165 try_consume_string_from_options(format_encode_options, key).ok_or(RwError::from(ProtocolError(
166 format!("missing field {} in options", key),
167 )))
168}
169
170fn consume_aws_config_from_options(format_encode_options: &mut BTreeMap<String, String>) {
171 format_encode_options.retain(|key, _| !key.starts_with("aws."))
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum CreateSourceType {
176 SharedCdc,
177 SharedNonCdc,
179 NonShared,
180 Table,
182}
183
184impl CreateSourceType {
185 pub fn for_newly_created(
187 session: &SessionImpl,
188 with_properties: &impl WithPropertiesExt,
189 ) -> Self {
190 if with_properties.is_shareable_cdc_connector() {
191 CreateSourceType::SharedCdc
192 } else if with_properties.is_shareable_non_cdc_connector()
193 && session
194 .env()
195 .streaming_config()
196 .developer
197 .enable_shared_source
198 && session.config().streaming_use_shared_source()
199 {
200 CreateSourceType::SharedNonCdc
201 } else {
202 CreateSourceType::NonShared
203 }
204 }
205
206 pub fn for_replace(catalog: &SourceCatalog) -> Self {
207 if !catalog.info.is_shared() {
208 CreateSourceType::NonShared
209 } else if catalog.with_properties.is_shareable_cdc_connector() {
210 CreateSourceType::SharedCdc
211 } else {
212 CreateSourceType::SharedNonCdc
213 }
214 }
215
216 pub fn is_shared(&self) -> bool {
217 matches!(
218 self,
219 CreateSourceType::SharedCdc | CreateSourceType::SharedNonCdc
220 )
221 }
222}
223
224pub(crate) fn bind_all_columns(
226 format_encode: &FormatEncodeOptions,
227 cols_from_source: Option<Vec<ColumnCatalog>>,
228 cols_from_sql: Vec<ColumnCatalog>,
229 col_defs_from_sql: &[ColumnDef],
230 wildcard_idx: Option<usize>,
231 sql_column_strategy: SqlColumnStrategy,
232) -> Result<Vec<ColumnCatalog>> {
233 if let Some(cols_from_source) = cols_from_source {
234 let generated_cols_from_sql = cols_from_sql
237 .iter()
238 .filter(|c| {
239 col_defs_from_sql
240 .iter()
241 .find(|d| d.name.real_value() == c.name())
242 .unwrap()
243 .is_generated()
244 })
245 .cloned()
246 .collect_vec();
247
248 match sql_column_strategy {
249 SqlColumnStrategy::FollowUnchecked => {
251 assert!(
252 wildcard_idx.is_none(),
253 "wildcard still exists while strategy is Follows, not correctly purified?"
254 );
255 return Ok(cols_from_sql);
256 }
257
258 SqlColumnStrategy::Ignore => {}
260
261 SqlColumnStrategy::FollowChecked => {
262 let has_regular_cols_from_sql =
263 generated_cols_from_sql.len() != cols_from_sql.len();
264
265 if has_regular_cols_from_sql {
266 if wildcard_idx.is_some() {
267 return Err(RwError::from(NotSupported(
269 "When there's a wildcard (\"*\"), \
270 only generated columns are allowed in user-defined schema from SQL"
271 .to_owned(),
272 "Remove the non-generated columns".to_owned(),
273 )));
274 } else {
275 for col in &cols_from_sql {
278 if generated_cols_from_sql.contains(col) {
279 continue;
280 }
281 let Some(col_from_source) =
282 cols_from_source.iter().find(|c| c.name() == col.name())
283 else {
284 return Err(RwError::from(ProtocolError(format!(
285 "Column \"{}\" is defined in SQL but not found in the source",
286 col.name()
287 ))));
288 };
289
290 if col_from_source.data_type() != col.data_type() {
291 return Err(RwError::from(ProtocolError(format!(
292 "Data type mismatch for column \"{}\". \
293 Defined in SQL as \"{}\", but found in the source as \"{}\"",
294 col.name(),
295 col.data_type(),
296 col_from_source.data_type()
297 ))));
298 }
299 }
300 return Ok(cols_from_sql);
301 }
302 } else {
303 if wildcard_idx.is_some() {
304 } else {
308 notice_to_user("\
316 Neither wildcard (\"*\") nor regular (non-generated) columns appear in the user-defined schema from SQL. \
317 For backward compatibility, all columns from the source will be included at the beginning. \
318 For clarity, consider adding a wildcard (\"*\") to indicate where the columns from the source should be included, \
319 or specifying the columns you want to include from the source.
320 ");
321 }
322 }
323 }
324 }
325
326 let wildcard_idx = wildcard_idx.unwrap_or(0).min(generated_cols_from_sql.len());
331
332 let mut merged_cols = generated_cols_from_sql;
334 let merged_cols_r = merged_cols.split_off(wildcard_idx);
335 merged_cols.extend(cols_from_source);
336 merged_cols.extend(merged_cols_r);
337
338 Ok(merged_cols)
339 } else {
340 if wildcard_idx.is_some() {
341 return Err(RwError::from(NotSupported(
342 "Wildcard in user-defined schema is only allowed when there exists columns from external schema".to_owned(),
343 "Remove the wildcard or use a source with external schema".to_owned(),
344 )));
345 }
346 let non_generated_sql_defined_columns = non_generated_sql_columns(col_defs_from_sql);
347
348 match (&format_encode.format, &format_encode.row_encode) {
349 (Format::DebeziumMongo, Encode::Json) => {
350 let strong_schema = format_encode
351 .row_options
352 .iter()
353 .find(|k| k.name.real_value().to_lowercase() == CDC_MONGODB_STRONG_SCHEMA_KEY)
354 .map(|k| matches!(k.value, SqlOptionValue::Value(Value::Boolean(true))))
355 .unwrap_or(false);
356
357 if strong_schema {
359 let (_, id_column) = non_generated_sql_defined_columns
360 .iter()
361 .enumerate()
362 .find(|(idx, col)| *idx == 0 && col.name.real_value() == "_id")
363 .ok_or_else(|| {
364 RwError::from(ProtocolError(
365 "The `_id` column of the source with row format DebeziumMongoJson must be defined as the first column in SQL".to_owned(),
366 ))
367 })?;
368
369 let id_data_type = bind_data_type(id_column.data_type.as_ref().unwrap())?;
370 if !matches!(
371 id_data_type,
372 DataType::Varchar | DataType::Int32 | DataType::Int64 | DataType::Jsonb
373 ) {
374 return Err(RwError::from(ProtocolError(
375 "the `_id` column of the source with row format DebeziumMongoJson must be [Jsonb | Varchar | Int32 | Int64]".to_owned(),
376 )));
377 }
378
379 let mut columns = Vec::with_capacity(non_generated_sql_defined_columns.len());
380 columns.push(
381 ColumnCatalog {
383 column_desc: ColumnDesc::named("_id", 0.into(), id_data_type),
384 is_hidden: false,
385 },
386 );
387
388 for (idx, col) in non_generated_sql_defined_columns
390 .into_iter()
391 .skip(1)
393 .enumerate()
394 {
395 columns.push(ColumnCatalog {
396 column_desc: ColumnDesc::named(
397 col.name.real_value(),
398 (idx as i32).into(),
399 bind_data_type(col.data_type.as_ref().unwrap())?,
400 ),
401 is_hidden: false,
402 });
403 }
404
405 return Ok(columns);
406 }
407
408 let mut columns = vec![
409 ColumnCatalog {
410 column_desc: ColumnDesc::named("_id", 0.into(), DataType::Varchar),
411 is_hidden: false,
412 },
413 ColumnCatalog {
414 column_desc: ColumnDesc::named("payload", 0.into(), DataType::Jsonb),
415 is_hidden: false,
416 },
417 ];
418
419 if non_generated_sql_defined_columns.len() != 2
420 || non_generated_sql_defined_columns[0].name.real_value() != columns[0].name()
421 || non_generated_sql_defined_columns[1].name.real_value() != columns[1].name()
422 {
423 return Err(RwError::from(ProtocolError(
424 "the not generated columns of the source with row format DebeziumMongoJson
425 must be (_id [Jsonb | Varchar | Int32 | Int64], payload jsonb)."
426 .to_owned(),
427 )));
428 }
429 let key_data_type = bind_data_type(
431 non_generated_sql_defined_columns[0]
432 .data_type
433 .as_ref()
434 .unwrap(),
435 )?;
436 match key_data_type {
437 DataType::Jsonb | DataType::Varchar | DataType::Int32 | DataType::Int64 => {
438 columns[0].column_desc.data_type = key_data_type;
439 }
440 _ => {
441 return Err(RwError::from(ProtocolError(
442 "the `_id` column of the source with row format DebeziumMongoJson
443 must be [Jsonb | Varchar | Int32 | Int64]"
444 .to_owned(),
445 )));
446 }
447 }
448
449 let value_data_type = bind_data_type(
451 non_generated_sql_defined_columns[1]
452 .data_type
453 .as_ref()
454 .unwrap(),
455 )?;
456 if !matches!(value_data_type, DataType::Jsonb) {
457 return Err(RwError::from(ProtocolError(
458 "the `payload` column of the source with row format DebeziumMongoJson
459 must be Jsonb datatype"
460 .to_owned(),
461 )));
462 }
463 Ok(columns)
464 }
465 (Format::Plain, Encode::Bytes) => {
466 let err = Err(RwError::from(ProtocolError(
467 "ENCODE BYTES only accepts one BYTEA type column".to_owned(),
468 )));
469 if non_generated_sql_defined_columns.len() == 1 {
470 let col_data_type = bind_data_type(
472 non_generated_sql_defined_columns[0]
473 .data_type
474 .as_ref()
475 .unwrap(),
476 )?;
477 if col_data_type == DataType::Bytea {
478 Ok(cols_from_sql)
479 } else {
480 err
481 }
482 } else {
483 err
484 }
485 }
486 (_, _) => Ok(cols_from_sql),
487 }
488 }
489}
490
491fn hint_format_encode(format_encode: &FormatEncodeOptions) -> String {
493 format!(
494 r#"Hint: For FORMAT {0} ENCODE {1}, INCLUDE KEY must be specified and the key column must be used as primary key.
495example:
496 CREATE TABLE <table_name> ( PRIMARY KEY ([rw_key | <key_name>]) )
497 INCLUDE KEY [AS <key_name>]
498 WITH (...)
499 FORMAT {0} ENCODE {1}{2}
500"#,
501 format_encode.format,
502 format_encode.row_encode,
503 if format_encode.row_encode == Encode::Json || format_encode.row_encode == Encode::Bytes {
504 "".to_owned()
505 } else {
506 " (...)".to_owned()
507 }
508 )
509}
510
511pub(crate) async fn bind_source_pk(
514 format_encode: &FormatEncodeOptions,
515 source_info: &StreamSourceInfo,
516 columns: &mut [ColumnCatalog],
517 sql_defined_pk_names: Vec<String>,
518 with_properties: &WithOptionsSecResolved,
519) -> Result<Vec<String>> {
520 let sql_defined_pk = !sql_defined_pk_names.is_empty();
521 let include_key_column_name: Option<String> = {
522 columns.iter().find_map(|catalog| {
525 if matches!(
526 catalog.column_desc.additional_column.column_type,
527 Some(AdditionalColumnType::Key(_))
528 ) {
529 Some(catalog.name().to_owned())
530 } else {
531 None
532 }
533 })
534 };
535 let additional_column_names = columns
536 .iter()
537 .filter_map(|col| {
538 if col.column_desc.additional_column.column_type.is_some() {
539 Some(col.name().to_owned())
540 } else {
541 None
542 }
543 })
544 .collect_vec();
545
546 let res = match (&format_encode.format, &format_encode.row_encode) {
547 (Format::Native, Encode::Native) | (Format::None, Encode::None) | (Format::Plain, _) => {
548 sql_defined_pk_names
549 }
550
551 (Format::Upsert, Encode::Json | Encode::Avro | Encode::Protobuf) => {
554 if let Some(ref key_column_name) = include_key_column_name
555 && sql_defined_pk
556 {
557 if sql_defined_pk_names.len() != 1
562 || !key_column_name.eq(sql_defined_pk_names[0].as_str())
563 {
564 return Err(RwError::from(ProtocolError(format!(
565 "Only \"{}\" can be used as primary key\n\n{}",
566 key_column_name,
567 hint_format_encode(format_encode)
568 ))));
569 }
570 sql_defined_pk_names
571 } else {
572 return if let Some(include_key_column_name) = include_key_column_name {
574 Err(RwError::from(ProtocolError(format!(
575 "Primary key must be specified to {}\n\n{}",
576 include_key_column_name,
577 hint_format_encode(format_encode)
578 ))))
579 } else {
580 Err(RwError::from(ProtocolError(format!(
581 "INCLUDE KEY clause not set\n\n{}",
582 hint_format_encode(format_encode)
583 ))))
584 };
585 }
586 }
587
588 (Format::Debezium, Encode::Json) => {
589 if !additional_column_names.is_empty() {
590 return Err(RwError::from(ProtocolError(format!(
591 "FORMAT DEBEZIUM forbids additional columns, but got {:?}",
592 additional_column_names
593 ))));
594 }
595 if !sql_defined_pk {
596 return Err(RwError::from(ProtocolError(
597 "Primary key must be specified when creating source with FORMAT DEBEZIUM."
598 .to_owned(),
599 )));
600 }
601 sql_defined_pk_names
602 }
603 (Format::Debezium, Encode::Avro) => {
604 if !additional_column_names.is_empty() {
605 return Err(RwError::from(ProtocolError(format!(
606 "FORMAT DEBEZIUM forbids additional columns, but got {:?}",
607 additional_column_names
608 ))));
609 }
610 if sql_defined_pk {
611 sql_defined_pk_names
612 } else {
613 let pk_names =
614 extract_debezium_avro_table_pk_columns(source_info, with_properties).await?;
615 for pk_name in &pk_names {
617 columns
618 .iter()
619 .find(|c: &&ColumnCatalog| c.name().eq(pk_name))
620 .ok_or_else(|| {
621 RwError::from(ProtocolError(format!(
622 "avro's key column {} not exists in avro's row schema",
623 pk_name
624 )))
625 })?;
626 }
627 pk_names
628 }
629 }
630 (Format::DebeziumMongo, Encode::Json) => {
631 if sql_defined_pk {
632 sql_defined_pk_names
633 } else {
634 vec!["_id".to_owned()]
635 }
636 }
637
638 (Format::Maxwell, Encode::Json) => {
639 if !additional_column_names.is_empty() {
640 return Err(RwError::from(ProtocolError(format!(
641 "FORMAT MAXWELL forbids additional columns, but got {:?}",
642 additional_column_names
643 ))));
644 }
645 if !sql_defined_pk {
646 return Err(RwError::from(ProtocolError(
647 "Primary key must be specified when creating source with FORMAT MAXWELL ENCODE JSON.".to_owned(),
648 )));
649 }
650 sql_defined_pk_names
651 }
652
653 (Format::Canal, Encode::Json) => {
654 if !additional_column_names.is_empty() {
655 return Err(RwError::from(ProtocolError(format!(
656 "FORMAT CANAL forbids additional columns, but got {:?}",
657 additional_column_names
658 ))));
659 }
660 if !sql_defined_pk {
661 return Err(RwError::from(ProtocolError(
662 "Primary key must be specified when creating source with FORMAT CANAL ENCODE JSON.".to_owned(),
663 )));
664 }
665 sql_defined_pk_names
666 }
667 (format, encoding) => {
668 return Err(RwError::from(ProtocolError(format!(
669 "Unknown combination {:?} {:?}",
670 format, encoding
671 ))));
672 }
673 };
674 Ok(res)
675}
676
677pub(super) fn bind_source_watermark(
678 session: &SessionImpl,
679 name: String,
680 source_watermarks: Vec<SourceWatermark>,
681 column_catalogs: &[ColumnCatalog],
682) -> Result<Vec<WatermarkDesc>> {
683 let mut binder = Binder::new_for_ddl(session);
684 binder.bind_columns_to_context(name.clone(), column_catalogs)?;
685
686 let mut session_tz = SessionTimezone::new(session.config().timezone());
687
688 let watermark_descs = source_watermarks
689 .into_iter()
690 .map(|source_watermark| {
691 let col_name = source_watermark.column.real_value();
692 let watermark_idx = binder.get_column_binding_index(name.clone(), &col_name)?;
693
694 let expr = binder.bind_expr(&source_watermark.expr)?;
695 let expr = session_tz.rewrite_expr(expr);
699 let watermark_col_type = column_catalogs[watermark_idx].data_type();
700 let watermark_expr_type = &expr.return_type();
701 if watermark_col_type != watermark_expr_type {
702 Err(RwError::from(ErrorCode::BindError(
703 format!("The return value type of the watermark expression must be identical to the watermark column data type. Current data type of watermark return value: `{}`, column `{}`",watermark_expr_type, watermark_col_type),
704 )))
705 } else {
706 let expr_proto = expr.to_expr_proto();
707 Ok::<_, RwError>(WatermarkDesc {
708 watermark_idx: watermark_idx as u32,
709 expr: Some(expr_proto),
710 with_ttl: source_watermark.with_ttl,
711 })
712 }
713 })
714 .try_collect()?;
715 Ok(watermark_descs)
716}
717
718pub(super) fn check_format_encode(
724 props: &WithOptionsSecResolved,
725 row_id_index: Option<usize>,
726 columns: &[ColumnCatalog],
727) -> Result<()> {
728 let Some(connector) = props.get_connector() else {
729 return Ok(());
730 };
731
732 if connector == NEXMARK_CONNECTOR {
733 check_nexmark_schema(props, row_id_index, columns)
734 } else {
735 Ok(())
736 }
737}
738
739pub fn bind_connector_props(
740 handler_args: &HandlerArgs,
741 format_encode: &FormatEncodeOptions,
742 is_create_source: bool,
743) -> Result<(WithOptions, SourceRefreshMode)> {
744 let mut with_properties = handler_args.with_options.clone().into_connector_props();
745 validate_compatibility(format_encode, &mut with_properties)?;
746 let refresh_mode = {
747 let refresh_mode = resolve_source_refresh_mode_in_with_option(&mut with_properties)?;
748 if is_create_source && refresh_mode.is_some() {
749 return Err(RwError::from(ProtocolError(
750 "`refresh_mode` only supported for CREATE TABLE".to_owned(),
751 )));
752 }
753
754 refresh_mode.unwrap_or(SourceRefreshMode {
755 refresh_mode: Some(RefreshMode::Streaming(SourceRefreshModeStreaming {})),
756 })
757 };
758
759 let create_cdc_source_job = with_properties.is_shareable_cdc_connector();
760
761 if !is_create_source && with_properties.is_shareable_cdc_connector() {
762 return Err(RwError::from(ProtocolError(format!(
763 "directly creating a CDC table for connector {} is no longer supported; \
764 please `CREATE SOURCE` to create a shared CDC source first, \
765 then `CREATE TABLE ... FROM <source> TABLE '<database>.<table>'`",
766 with_properties.get_connector().unwrap(),
767 ))));
768 }
769 if is_create_source && create_cdc_source_job {
770 if let Some(value) = with_properties.get(AUTO_SCHEMA_CHANGE_KEY)
771 && value.parse::<bool>().map_err(|_| {
772 ErrorCode::InvalidInputSyntax(format!(
773 "invalid value of '{}' option",
774 AUTO_SCHEMA_CHANGE_KEY
775 ))
776 })?
777 {
778 Feature::CdcAutoSchemaChange.check_available()?;
779 }
780
781 with_properties.insert(CDC_SNAPSHOT_MODE_KEY.into(), CDC_SNAPSHOT_BACKFILL.into());
783 with_properties.insert(CDC_SHARING_MODE_KEY.into(), "true".into());
785 if with_properties.enable_transaction_metadata() {
787 with_properties.insert(CDC_TRANSACTIONAL_KEY.into(), "true".into());
788 }
789 if !with_properties.contains_key(CDC_WAIT_FOR_STREAMING_START_TIMEOUT) {
791 with_properties.insert(
792 CDC_WAIT_FOR_STREAMING_START_TIMEOUT.into(),
793 handler_args
794 .session
795 .config()
796 .cdc_source_wait_streaming_start_timeout()
797 .to_string(),
798 );
799 }
800 }
801 if with_properties.is_mysql_cdc_connector() {
802 with_properties
806 .entry("server.id".to_owned())
807 .or_insert(rand::rng().random_range(1..u32::MAX).to_string());
808 }
809 Ok((with_properties, refresh_mode))
810}
811
812fn must_wait_cdc_offset_before_report(with_properties: &WithOptions) -> bool {
813 matches!(
814 with_properties.get_connector().as_deref(),
815 Some(MYSQL_CDC_CONNECTOR) | Some(SQL_SERVER_CDC_CONNECTOR)
816 )
817}
818
819pub enum SqlColumnStrategy {
822 FollowUnchecked,
827
828 FollowChecked,
835
836 Ignore,
841}
842
843pub(crate) fn reject_variant_columns(columns: &[ColumnCatalog], context: &str) -> Result<()> {
846 if let Some(col) = columns.iter().find(|c| c.data_type().contains_variant()) {
847 return Err(RwError::from(NotSupported(
848 format!(
849 "VARIANT column \"{}\" is not supported {context} yet",
850 col.name()
851 ),
852 format!("VARIANT columns are not supported {context} yet"),
853 )));
854 }
855 Ok(())
856}
857
858fn reject_variant_columns_for_unsupported_encoding(
864 format_encode: &FormatEncodeOptions,
865 columns_from_sql: &[ColumnCatalog],
866) -> Result<()> {
867 if matches!(
868 (&format_encode.format, &format_encode.row_encode),
869 (Format::None, Encode::None) | (_, Encode::Parquet)
870 ) {
871 return Ok(());
872 }
873 reject_variant_columns(columns_from_sql, "for this source encoding")
874}
875
876#[expect(clippy::too_many_arguments)]
879pub async fn bind_create_source_or_table_with_connector(
880 handler_args: HandlerArgs,
881 full_name: ObjectName,
882 format_encode: FormatEncodeOptions,
883 with_properties: WithOptions,
884 sql_columns_defs: &[ColumnDef],
885 constraints: Vec<TableConstraint>,
886 wildcard_idx: Option<usize>,
887 source_watermarks: Vec<SourceWatermark>,
888 columns_from_resolve_source: Option<Vec<ColumnCatalog>>,
889 source_info: StreamSourceInfo,
890 include_column_options: IncludeOption,
891 col_id_gen: &mut ColumnIdGenerator,
892 create_source_type: CreateSourceType,
893 source_rate_limit: Option<u32>,
894 sql_column_strategy: SqlColumnStrategy,
895 refresh_mode: SourceRefreshMode,
896) -> Result<SourceCatalog> {
897 let session = &handler_args.session;
898 let db_name: &str = &session.database();
899 let (schema_name, source_name) = Binder::resolve_schema_qualified_name(db_name, &full_name)?;
900 let (database_id, schema_id) =
901 session.get_database_and_schema_id_for_create(schema_name.clone())?;
902
903 let is_create_source = create_source_type != CreateSourceType::Table;
904
905 if is_create_source {
906 if with_properties.is_batch_connector() {
908 return Err(ErrorCode::BindError(
909 "can't CREATE SOURCE with refreshable batch connector\n\nHint: use CREATE TABLE instead"
910 .to_owned(),
911 )
912 .into());
913 }
914
915 match format_encode.format {
916 Format::Debezium | Format::DebeziumMongo | Format::Maxwell | Format::Canal => {
918 return Err(ErrorCode::BindError(format!(
919 "can't CREATE SOURCE with FORMAT {}.\n\nHint: use CREATE TABLE instead\n\n{}",
920 format_encode.format,
921 hint_format_encode(&format_encode)
922 ))
923 .into());
924 }
925 Format::Upsert => {
927 notice_to_user(format!(
928 "Streaming queries on sources with `FORMAT {}` may have limitations. If your query isn't supported, consider using `CREATE TABLE` instead.",
929 format_encode.format
930 ));
931 }
932 _ => {}
933 }
934 }
935
936 let sql_pk_names = bind_sql_pk_names(sql_columns_defs, bind_table_constraints(&constraints)?)?;
937
938 if with_properties.is_iceberg_connector() {
939 if is_create_source && !sql_pk_names.is_empty() {
940 return Err(ErrorCode::NotSupported(
941 "PRIMARY KEY is not supported for Iceberg CREATE SOURCE in continuous ingestion mode."
942 .to_owned(),
943 "Iceberg streaming ingestion only supports append-only sources. Remove the PRIMARY KEY clause."
944 .to_owned(),
945 )
946 .into());
947 }
948
949 if !sql_columns_defs.is_empty() {
953 return Err(RwError::from(InvalidInputSyntax(
954 r#"Schema is automatically inferred for iceberg source and should not be specified
955
956HINT: use `CREATE SOURCE <name> WITH (...)` instead of `CREATE SOURCE <name> (<columns>) WITH (...)`."#.to_owned(),
957 )));
958 }
959 }
960
961 if with_properties.is_batch_connector()
963 && with_properties
964 .get(UPSTREAM_SOURCE_KEY)
965 .is_some_and(|s| s.eq_ignore_ascii_case(ADBC_SNOWFLAKE_CONNECTOR))
966 && !sql_columns_defs.is_empty()
967 {
968 return Err(RwError::from(InvalidInputSyntax(
969 r#"Schema is automatically inferred for ADBC Snowflake source and should not be specified
970
971HINT: use `CREATE TABLE <name> WITH (...)` instead of `CREATE TABLE <name> (<columns>) WITH (...)`."#.to_owned(),
972 )));
973 }
974 let columns_from_sql = bind_sql_columns(sql_columns_defs, false)?;
975
976 reject_variant_columns_for_unsupported_encoding(&format_encode, &columns_from_sql)?;
977
978 let mut columns = bind_all_columns(
979 &format_encode,
980 columns_from_resolve_source,
981 columns_from_sql,
982 sql_columns_defs,
983 wildcard_idx,
984 sql_column_strategy,
985 )?;
986
987 handle_addition_columns(
989 Some(&format_encode),
990 &with_properties,
991 include_column_options,
992 &mut columns,
993 false,
994 )?;
995
996 if columns.is_empty() {
997 return Err(RwError::from(ProtocolError(
998 "Schema definition is required, either from SQL or schema registry.".to_owned(),
999 )));
1000 }
1001
1002 if is_create_source {
1004 check_and_add_timestamp_column(&with_properties, &mut columns);
1006
1007 if create_source_type == CreateSourceType::SharedNonCdc {
1010 let (columns_exist, additional_columns) = source_add_partition_offset_cols(
1011 &columns,
1012 &with_properties.get_connector().unwrap(),
1013 true, );
1015 for (existed, c) in columns_exist.into_iter().zip_eq_fast(additional_columns) {
1016 if !existed {
1017 columns.push(ColumnCatalog::hidden(c));
1018 }
1019 }
1020 }
1021 }
1022
1023 let mut with_properties = with_properties;
1025 resolve_privatelink_in_with_option(&mut with_properties)?;
1026
1027 if session
1029 .env()
1030 .system_params_manager()
1031 .get_params()
1032 .load()
1033 .enforce_secret()
1034 && Feature::SecretManagement.check_available().is_ok()
1035 {
1036 ConnectorProperties::enforce_secret_source(&with_properties)?;
1038 }
1039
1040 let (with_properties, connection_type, connector_conn_ref) =
1041 resolve_connection_ref_and_secret_ref(
1042 with_properties,
1043 session,
1044 Some(TelemetryDatabaseObject::Source),
1045 )?;
1046 ensure_connection_type_allowed(connection_type, &SOURCE_ALLOWED_CONNECTION_CONNECTOR)?;
1047
1048 if !matches!(connection_type, PbConnectionType::Unspecified) {
1050 let Some(connector) = with_properties.get_connector() else {
1051 return Err(RwError::from(ProtocolError(format!(
1052 "missing field '{}' in WITH clause",
1053 UPSTREAM_SOURCE_KEY
1054 ))));
1055 };
1056 check_connector_match_connection_type(connector.as_str(), &connection_type)?;
1057 }
1058
1059 let pk_names = bind_source_pk(
1060 &format_encode,
1061 &source_info,
1062 &mut columns,
1063 sql_pk_names,
1064 &with_properties,
1065 )
1066 .await?;
1067
1068 if let Some(duplicated_name) = columns.iter().map(|c| c.name()).duplicates().next() {
1071 return Err(ErrorCode::InvalidInputSyntax(format!(
1072 "column \"{}\" specified more than once",
1073 duplicated_name
1074 ))
1075 .into());
1076 }
1077
1078 for c in &mut columns {
1080 let original_data_type = c.data_type().clone();
1081 col_id_gen.generate(c)?;
1082 if is_create_source {
1086 c.column_desc.data_type = original_data_type;
1087 }
1088 }
1089 debug_assert_column_ids_distinct(&columns);
1090
1091 let (mut columns, pk_col_ids, row_id_index) =
1092 bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
1093
1094 let watermark_descs =
1095 bind_source_watermark(session, source_name.clone(), source_watermarks, &columns)?;
1096 assert!(watermark_descs.len() <= 1);
1098 if is_create_source && watermark_descs.iter().any(|d| d.with_ttl) {
1099 return Err(ErrorCode::NotSupported(
1100 "WITH TTL is not supported in WATERMARK clause for CREATE SOURCE.".to_owned(),
1101 "Use `CREATE TABLE ... WATERMARK ... WITH TTL` instead.".to_owned(),
1102 )
1103 .into());
1104 }
1105
1106 let append_only = row_id_index.is_some();
1107 if is_create_source && !append_only && !watermark_descs.is_empty() {
1108 return Err(ErrorCode::NotSupported(
1109 "Defining watermarks on source requires the source connector to be append only."
1110 .to_owned(),
1111 "Use the key words `FORMAT PLAIN`".to_owned(),
1112 )
1113 .into());
1114 }
1115
1116 bind_sql_column_constraints(
1117 session,
1118 source_name.clone(),
1119 &mut columns,
1120 sql_columns_defs,
1122 &pk_col_ids,
1123 )?;
1124 check_format_encode(&with_properties, row_id_index, &columns)?;
1125
1126 let definition = handler_args.normalized_sql.clone();
1127
1128 let associated_table_id = if is_create_source {
1129 None
1130 } else {
1131 Some(TableId::placeholder())
1132 };
1133 let source = SourceCatalog {
1134 id: SourceId::placeholder(),
1135 name: source_name,
1136 schema_id,
1137 database_id,
1138 columns,
1139 pk_col_ids,
1140 append_only,
1141 owner: session.user_id(),
1142 info: source_info,
1143 row_id_index,
1144 with_properties,
1145 watermark_descs,
1146 associated_table_id,
1147 definition,
1148 connection_id: connector_conn_ref,
1149 created_at_epoch: None,
1150 initialized_at_epoch: None,
1151 version: INITIAL_SOURCE_VERSION_ID,
1152 created_at_cluster_version: None,
1153 initialized_at_cluster_version: None,
1154 rate_limit: source_rate_limit,
1155 refresh_mode: Some(refresh_mode),
1156 };
1157 Ok(source)
1158}
1159
1160pub async fn handle_create_source(
1161 mut handler_args: HandlerArgs,
1162 stmt: CreateSourceStatement,
1163) -> Result<RwPgResponse> {
1164 let session = handler_args.session.clone();
1165 let overwrite_options = OverwriteOptions::new(&mut handler_args);
1166
1167 if let Either::Right(resp) = session.check_relation_name_duplicated(
1168 stmt.source_name.clone(),
1169 StatementType::CREATE_SOURCE,
1170 stmt.if_not_exists,
1171 )? {
1172 return Ok(resp);
1173 }
1174
1175 if stmt
1176 .source_name
1177 .base_name()
1178 .starts_with(ICEBERG_SOURCE_PREFIX)
1179 {
1180 return Err(RwError::from(InvalidInputSyntax(format!(
1181 "Source name cannot start with reserved prefix '{}'",
1182 ICEBERG_SOURCE_PREFIX
1183 ))));
1184 }
1185
1186 if handler_args.with_options.is_empty() {
1187 return Err(RwError::from(InvalidInputSyntax(
1188 "missing WITH clause".to_owned(),
1189 )));
1190 }
1191
1192 if overwrite_options.source_rate_limit == Some(0)
1193 && must_wait_cdc_offset_before_report(&handler_args.with_options)
1194 {
1195 let connector = handler_args.with_options.get_connector().unwrap();
1196 return Err(RwError::from(ErrorCode::InvalidParameterValue(format!(
1197 "`source_rate_limit` cannot be 0 when creating a `{connector}` source because source creation must wait for the initial CDC offset."
1198 ))));
1199 }
1200
1201 let format_encode = stmt.format_encode.into_v2_with_warning();
1202 let (with_properties, refresh_mode) =
1203 bind_connector_props(&handler_args, &format_encode, true)?;
1204 if let Some(connector) = with_properties.get_connector() {
1205 ensure_local_fs_connector_allowed(&session, &connector)?;
1206 }
1207
1208 let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
1209 let (columns_from_resolve_source, source_info) = bind_columns_from_source(
1210 &session,
1211 &format_encode,
1212 Either::Left(&with_properties),
1213 create_source_type,
1214 )
1215 .await?;
1216 let mut col_id_gen = ColumnIdGenerator::new_initial();
1217
1218 if stmt.columns.iter().any(|col| {
1219 col.options
1220 .iter()
1221 .any(|def| matches!(def.option, ColumnOption::NotNull))
1222 }) {
1223 return Err(RwError::from(InvalidInputSyntax(
1224 "NOT NULL constraint is not supported in source schema".to_owned(),
1225 )));
1226 }
1227
1228 let source_catalog = bind_create_source_or_table_with_connector(
1229 handler_args.clone(),
1230 stmt.source_name,
1231 format_encode,
1232 with_properties,
1233 &stmt.columns,
1234 stmt.constraints,
1235 stmt.wildcard_idx,
1236 stmt.source_watermarks,
1237 columns_from_resolve_source,
1238 source_info,
1239 stmt.include_column_options,
1240 &mut col_id_gen,
1241 create_source_type,
1242 overwrite_options.source_rate_limit,
1243 SqlColumnStrategy::FollowChecked,
1244 refresh_mode,
1245 )
1246 .await?;
1247
1248 if stmt.temporary {
1250 if session.get_temporary_source(&source_catalog.name).is_some() {
1251 return Err(CatalogError::duplicated("source", source_catalog.name.clone()).into());
1252 }
1253 session.create_temporary_source(source_catalog);
1254 return Ok(PgResponse::empty_result(StatementType::CREATE_SOURCE));
1255 }
1256
1257 let source = source_catalog.to_prost();
1258
1259 let catalog_writer = session.catalog_writer()?;
1260
1261 if create_source_type.is_shared() {
1262 let graph = generate_stream_graph_for_source(handler_args, source_catalog)?;
1263 catalog_writer
1264 .create_source(source, Some(graph), stmt.if_not_exists)
1265 .await?;
1266 } else {
1267 catalog_writer
1269 .create_source(source, None, stmt.if_not_exists)
1270 .await?;
1271 }
1272
1273 Ok(PgResponse::empty_result(StatementType::CREATE_SOURCE))
1274}
1275
1276pub(super) fn generate_stream_graph_for_source(
1277 handler_args: HandlerArgs,
1278 source_catalog: SourceCatalog,
1279) -> Result<PbStreamFragmentGraph> {
1280 let context = OptimizerContext::from_handler_args(handler_args);
1281 let source_node = LogicalSource::with_catalog(
1282 Rc::new(source_catalog),
1283 SourceNodeKind::CreateSharedSource,
1284 context.into(),
1285 None,
1286 )?;
1287
1288 let stream_plan = source_node.to_stream(&mut ToStreamContext::new_with_backfill_type(
1289 false,
1290 BackfillType::ArrangementBackfill,
1293 ))?;
1294 let graph = build_graph(stream_plan, Some(GraphJobType::Source))?;
1295 Ok(graph)
1296}
1297
1298#[cfg(test)]
1299pub mod tests {
1300 use std::collections::HashMap;
1301 use std::sync::Arc;
1302
1303 use risingwave_common::catalog::{
1304 DEFAULT_DATABASE_NAME, DEFAULT_SCHEMA_NAME, ROW_ID_COLUMN_NAME,
1305 };
1306 use risingwave_common::config::FrontendConfig;
1307 use risingwave_common::types::{DataType, StructType};
1308 use risingwave_pb::plan_common::EncodeType;
1309
1310 use crate::catalog::root_catalog::SchemaPath;
1311 use crate::catalog::source_catalog::SourceCatalog;
1312 use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
1313
1314 const GET_COLUMN_FROM_CATALOG: fn(&Arc<SourceCatalog>) -> HashMap<&str, DataType> =
1315 |catalog: &Arc<SourceCatalog>| -> HashMap<&str, DataType> {
1316 catalog
1317 .columns
1318 .iter()
1319 .map(|col| (col.name(), col.data_type().clone()))
1320 .collect::<HashMap<&str, DataType>>()
1321 };
1322
1323 #[tokio::test]
1324 async fn test_create_source_handler() {
1325 let proto_file = create_proto_file(PROTO_FILE_DATA);
1326 let sql = format!(
1327 r#"CREATE SOURCE t
1328 WITH (connector = 'kinesis')
1329 FORMAT PLAIN ENCODE PROTOBUF (message = '.test.TestRecord', schema.location = 'file://{}')"#,
1330 proto_file.path().to_str().unwrap()
1331 );
1332 let frontend = LocalFrontend::new(Default::default()).await;
1333 frontend.run_sql(sql).await.unwrap();
1334
1335 let session = frontend.session_ref();
1336 let catalog_reader = session.env().catalog_reader().read_guard();
1337 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1338
1339 let (source, _) = catalog_reader
1341 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
1342 .unwrap();
1343 assert_eq!(source.name, "t");
1344
1345 let columns = GET_COLUMN_FROM_CATALOG(source);
1346
1347 let city_type = StructType::new(vec![
1348 ("address", DataType::Varchar),
1349 ("zipcode", DataType::Varchar),
1350 ])
1351 .into();
1353 let expected_columns = maplit::hashmap! {
1354 ROW_ID_COLUMN_NAME => DataType::Serial,
1355 "id" => DataType::Int32,
1356 "zipcode" => DataType::Int64,
1357 "rate" => DataType::Float32,
1358 "country" => StructType::new(
1359 vec![("address", DataType::Varchar),("city", city_type),("zipcode", DataType::Varchar)],
1360 )
1361 .into(),
1363 };
1364 assert_eq!(columns, expected_columns, "{columns:#?}");
1365 }
1366
1367 #[tokio::test]
1368 async fn test_create_mqtt_source_with_protobuf() {
1369 let proto_file = create_proto_file(PROTO_FILE_DATA);
1370 let sql = format!(
1371 r#"CREATE SOURCE t_mqtt
1372 WITH (
1373 connector = 'mqtt',
1374 url = 'mqtt://localhost:1883',
1375 topic = 'test_topic'
1376 )
1377 FORMAT PLAIN ENCODE PROTOBUF (
1378 message = '.test.TestRecord',
1379 schema.location = 'file://{}'
1380 )"#,
1381 proto_file.path().to_str().unwrap()
1382 );
1383 let frontend = LocalFrontend::new(Default::default()).await;
1384 frontend.run_sql(sql).await.unwrap();
1385
1386 let session = frontend.session_ref();
1387 let catalog_reader = session.env().catalog_reader().read_guard();
1388 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1389
1390 let (source, _) = catalog_reader
1391 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t_mqtt")
1392 .unwrap();
1393
1394 assert_eq!(source.name, "t_mqtt");
1395 assert_eq!(source.info.row_encode, EncodeType::Protobuf as i32);
1396 }
1397
1398 #[tokio::test]
1399 async fn test_create_posix_fs_source_requires_frontend_config() {
1400 let frontend = LocalFrontend::with_frontend_config(
1401 Default::default(),
1402 FrontendConfig {
1403 unsafe_enable_local_fs_connector: false,
1404 ..Default::default()
1405 },
1406 )
1407 .await;
1408 let err = frontend
1409 .run_sql(
1410 r#"CREATE SOURCE local_files (
1411 line VARCHAR
1412 ) WITH (
1413 connector = 'posix_fs',
1414 posix_fs.root = '/tmp',
1415 match_pattern = '*.csv'
1416 ) FORMAT PLAIN ENCODE CSV (without_header = 'true')"#
1417 .to_owned(),
1418 )
1419 .await
1420 .unwrap_err();
1421
1422 assert!(
1423 err.to_string()
1424 .contains("frontend.unsafe_enable_local_fs_connector = true"),
1425 "{err:?}"
1426 );
1427 }
1428
1429 #[tokio::test]
1430 async fn test_duplicate_props_options() {
1431 let proto_file = create_proto_file(PROTO_FILE_DATA);
1432 let sql = format!(
1433 r#"CREATE SOURCE t
1434 WITH (
1435 connector = 'kinesis',
1436 aws.region='user_test_topic',
1437 endpoint='172.10.1.1:9090,172.10.1.2:9090',
1438 aws.credentials.access_key_id = 'your_access_key_1',
1439 aws.credentials.secret_access_key = 'your_secret_key_1'
1440 )
1441 FORMAT PLAIN ENCODE PROTOBUF (
1442 message = '.test.TestRecord',
1443 aws.credentials.access_key_id = 'your_access_key_2',
1444 aws.credentials.secret_access_key = 'your_secret_key_2',
1445 schema.location = 'file://{}',
1446 )"#,
1447 proto_file.path().to_str().unwrap()
1448 );
1449 let frontend = LocalFrontend::new(Default::default()).await;
1450 frontend.run_sql(sql).await.unwrap();
1451
1452 let session = frontend.session_ref();
1453 let catalog_reader = session.env().catalog_reader().read_guard();
1454 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1455
1456 let (source, _) = catalog_reader
1458 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
1459 .unwrap();
1460 assert_eq!(source.name, "t");
1461
1462 assert_eq!(
1464 source
1465 .info
1466 .format_encode_options
1467 .get("aws.credentials.access_key_id")
1468 .unwrap(),
1469 "your_access_key_2"
1470 );
1471 assert_eq!(
1472 source
1473 .info
1474 .format_encode_options
1475 .get("aws.credentials.secret_access_key")
1476 .unwrap(),
1477 "your_secret_key_2"
1478 );
1479
1480 assert_eq!(
1482 source
1483 .with_properties
1484 .get("aws.credentials.access_key_id")
1485 .unwrap(),
1486 "your_access_key_1"
1487 );
1488 assert_eq!(
1489 source
1490 .with_properties
1491 .get("aws.credentials.secret_access_key")
1492 .unwrap(),
1493 "your_secret_key_1"
1494 );
1495
1496 assert!(!source.with_properties.contains_key("schema.location"));
1498 }
1499
1500 #[tokio::test]
1501 async fn test_multi_table_cdc_create_source_handler() {
1502 let sql =
1503 "CREATE SOURCE t2 WITH (connector = 'mysql-cdc') FORMAT PLAIN ENCODE JSON".to_owned();
1504 let frontend = LocalFrontend::new(Default::default()).await;
1505 let session = frontend.session_ref();
1506
1507 frontend
1508 .run_sql_with_session(session.clone(), sql)
1509 .await
1510 .unwrap();
1511 let catalog_reader = session.env().catalog_reader().read_guard();
1512 let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1513
1514 let (source, _) = catalog_reader
1516 .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t2")
1517 .unwrap();
1518 assert_eq!(source.name, "t2");
1519
1520 let columns = source
1521 .columns
1522 .iter()
1523 .map(|col| (col.name(), col.data_type().clone()))
1524 .collect::<Vec<(&str, DataType)>>();
1525
1526 expect_test::expect![[r#"
1527 [
1528 (
1529 "payload",
1530 Jsonb,
1531 ),
1532 (
1533 "_rw_offset",
1534 Varchar,
1535 ),
1536 (
1537 "_rw_table_name",
1538 Varchar,
1539 ),
1540 (
1541 "_row_id",
1542 Serial,
1543 ),
1544 ]
1545 "#]]
1546 .assert_debug_eq(&columns);
1547 }
1548
1549 #[tokio::test]
1550 async fn test_reject_zero_source_rate_limit_when_cdc_must_wait_for_offset() {
1551 let frontend = LocalFrontend::new(Default::default()).await;
1552 let session = frontend.session_ref();
1553
1554 for (source_name, connector) in [
1555 ("mysql_explicit_zero", "mysql-cdc"),
1556 ("sqlserver_explicit_zero", "sqlserver-cdc"),
1557 ] {
1558 let err = frontend
1559 .run_sql_with_session(
1560 session.clone(),
1561 format!(
1562 "CREATE SOURCE {source_name} WITH (connector = '{connector}', source_rate_limit = '0') FORMAT PLAIN ENCODE JSON"
1563 ),
1564 )
1565 .await
1566 .unwrap_err();
1567 let message = err.to_string();
1568 assert!(
1569 message.contains("source creation must wait for the initial CDC offset"),
1570 "{message}"
1571 );
1572 }
1573
1574 frontend
1575 .run_sql_with_session(session.clone(), "SET source_rate_limit TO 0;")
1576 .await
1577 .unwrap();
1578
1579 for (source_name, connector) in [
1580 ("mysql_session_zero", "mysql-cdc"),
1581 ("sqlserver_session_zero", "sqlserver-cdc"),
1582 ] {
1583 let err = frontend
1584 .run_sql_with_session(
1585 session.clone(),
1586 format!(
1587 "CREATE SOURCE {source_name} WITH (connector = '{connector}') FORMAT PLAIN ENCODE JSON"
1588 ),
1589 )
1590 .await
1591 .unwrap_err();
1592 assert!(
1593 err.to_string()
1594 .contains("source creation must wait for the initial CDC offset"),
1595 "{err}"
1596 );
1597 }
1598
1599 frontend
1600 .run_sql_with_session(
1601 session.clone(),
1602 "CREATE SOURCE mysql_positive_override WITH (connector = 'mysql-cdc', source_rate_limit = '1') FORMAT PLAIN ENCODE JSON",
1603 )
1604 .await
1605 .unwrap();
1606
1607 frontend
1608 .run_sql_with_session(
1609 session,
1610 "CREATE SOURCE postgres_zero WITH (connector = 'postgres-cdc') FORMAT PLAIN ENCODE JSON",
1611 )
1612 .await
1613 .unwrap();
1614 }
1615
1616 #[tokio::test]
1617 async fn test_source_addition_columns() {
1618 let sql =
1620 "CREATE SOURCE s (v1 int) include key as _rw_kafka_key with (connector = 'kafka') format plain encode json".to_owned();
1621 let frontend = LocalFrontend::new(Default::default()).await;
1622 frontend.run_sql(sql).await.unwrap();
1623 let session = frontend.session_ref();
1624 let catalog_reader = session.env().catalog_reader().read_guard();
1625 let (source, _) = catalog_reader
1626 .get_source_by_name(
1627 DEFAULT_DATABASE_NAME,
1628 SchemaPath::Name(DEFAULT_SCHEMA_NAME),
1629 "s",
1630 )
1631 .unwrap();
1632 assert_eq!(source.name, "s");
1633
1634 let columns = source
1635 .columns
1636 .iter()
1637 .map(|col| (col.name(), col.data_type().clone()))
1638 .collect::<Vec<(&str, DataType)>>();
1639
1640 expect_test::expect![[r#"
1641 [
1642 (
1643 "v1",
1644 Int32,
1645 ),
1646 (
1647 "_rw_kafka_key",
1648 Bytea,
1649 ),
1650 (
1651 "_rw_kafka_timestamp",
1652 Timestamptz,
1653 ),
1654 (
1655 "_rw_kafka_partition",
1656 Varchar,
1657 ),
1658 (
1659 "_rw_kafka_offset",
1660 Varchar,
1661 ),
1662 (
1663 "_row_id",
1664 Serial,
1665 ),
1666 ]
1667 "#]]
1668 .assert_debug_eq(&columns);
1669 drop(catalog_reader);
1670
1671 let sql =
1672 "CREATE SOURCE s_pulsar (v1 int) include header 'tenant' as pulsar_header with (connector = 'pulsar') format plain encode json".to_owned();
1673 frontend.run_sql(sql).await.unwrap();
1674 let catalog_reader = session.env().catalog_reader().read_guard();
1675 let (source, _) = catalog_reader
1676 .get_source_by_name(
1677 DEFAULT_DATABASE_NAME,
1678 SchemaPath::Name(DEFAULT_SCHEMA_NAME),
1679 "s_pulsar",
1680 )
1681 .unwrap();
1682 assert_eq!(source.name, "s_pulsar");
1683
1684 let columns = source
1685 .columns
1686 .iter()
1687 .map(|col| (col.name(), col.data_type().clone()))
1688 .collect::<Vec<(&str, DataType)>>();
1689
1690 expect_test::expect![[r#"
1691 [
1692 (
1693 "v1",
1694 Int32,
1695 ),
1696 (
1697 "pulsar_header",
1698 Bytea,
1699 ),
1700 (
1701 "_row_id",
1702 Serial,
1703 ),
1704 ]
1705 "#]]
1706 .assert_debug_eq(&columns);
1707 drop(catalog_reader);
1708
1709 let sql =
1710 "CREATE SOURCE s3 (v1 int) include timestamp 'header1' as header_col with (connector = 'kafka') format plain encode json".to_owned();
1711 match frontend.run_sql(sql).await {
1712 Err(e) => {
1713 assert_eq!(
1714 e.to_string(),
1715 "Protocol error: Only header column can have inner field, but got \"timestamp\""
1716 )
1717 }
1718 _ => unreachable!(),
1719 }
1720 }
1721}