Skip to main content

risingwave_frontend/handler/
create_source.rs

1// Copyright 2022 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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    CdcTableDesc, ColumnCatalog, ColumnDesc, ColumnId, INITIAL_SOURCE_VERSION_ID,
31    KAFKA_TIMESTAMP_COLUMN_NAME, 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_BACKFILL_ENABLE_KEY, CDC_MONGODB_STRONG_SCHEMA_KEY, CDC_SHARING_MODE_KEY,
56    CDC_SNAPSHOT_BACKFILL, CDC_SNAPSHOT_MODE_KEY, CDC_TRANSACTIONAL_KEY,
57    CDC_WAIT_FOR_STREAMING_START_TIMEOUT, CITUS_CDC_CONNECTOR, MONGODB_CDC_CONNECTOR,
58    MYSQL_CDC_CONNECTOR, POSTGRES_CDC_CONNECTOR, 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::root_catalog::SchemaPath;
91use crate::catalog::source_catalog::SourceCatalog;
92use crate::error::ErrorCode::{self, Deprecated, InvalidInputSyntax, NotSupported, ProtocolError};
93use crate::error::{Result, RwError};
94use crate::expr::{Expr, ExprRewriter, SessionTimezone};
95use crate::handler::HandlerArgs;
96use crate::handler::cdc::{
97    bind_cdc_pk_comparisons_externally, bind_cdc_table_schema, bind_cdc_table_schema_externally,
98    derive_with_options_for_cdc_table, not_null_check_for_cdc_table,
99    reject_pk_filtered_by_debezium_column_filter, sanity_check_for_table_on_cdc_source,
100};
101use crate::handler::create_table::{
102    ColumnIdGenerator, bind_pk_and_row_id_on_relation, bind_sql_column_constraints,
103    bind_sql_columns, bind_sql_pk_names, bind_table_constraints, check_cdc_source_select_privilege,
104};
105use crate::handler::util::{
106    SourceSchemaCompatExt, check_connector_match_connection_type, ensure_connection_type_allowed,
107    ensure_local_fs_connector_allowed,
108};
109use crate::optimizer::plan_node::generic::SourceNodeKind;
110use crate::optimizer::plan_node::{BackfillType, LogicalSource, ToStream, ToStreamContext};
111use crate::session::SessionImpl;
112use crate::session::current::notice_to_user;
113use crate::utils::{
114    OverwriteOptions, resolve_connection_ref_and_secret_ref, resolve_privatelink_in_with_option,
115    resolve_secret_ref_in_with_options, resolve_source_refresh_mode_in_with_option,
116};
117use crate::{OptimizerContext, WithOptions, WithOptionsSecResolved, bind_data_type, build_graph};
118
119mod external_schema;
120pub use external_schema::{
121    bind_columns_from_source, get_schema_location, schema_has_schema_registry,
122};
123mod validate;
124use validate::{SOURCE_ALLOWED_CONNECTION_CONNECTOR, SOURCE_ALLOWED_CONNECTION_SCHEMA_REGISTRY};
125pub use validate::{validate_compatibility, validate_heartbeat_interval};
126mod additional_column;
127use additional_column::check_and_add_timestamp_column;
128pub use additional_column::handle_addition_columns;
129use risingwave_common::catalog::ICEBERG_SOURCE_PREFIX;
130use risingwave_common::id::SourceId;
131
132use crate::stream_fragmenter::GraphJobType;
133
134fn non_generated_sql_columns(columns: &[ColumnDef]) -> Vec<ColumnDef> {
135    columns
136        .iter()
137        .filter(|c| !c.is_generated())
138        .cloned()
139        .collect()
140}
141
142fn try_consume_string_from_options(
143    format_encode_options: &mut BTreeMap<String, String>,
144    key: &str,
145) -> Option<AstString> {
146    format_encode_options.remove(key).map(AstString)
147}
148
149fn try_consume_schema_registry_config_from_options(
150    format_encode_options: &mut BTreeMap<String, String>,
151) {
152    [
153        SCHEMA_REGISTRY_USERNAME,
154        SCHEMA_REGISTRY_PASSWORD,
155        SCHEMA_REGISTRY_CA_PEM_PATH,
156        SCHEMA_REGISTRY_MAX_DELAY_KEY,
157        SCHEMA_REGISTRY_BACKOFF_DURATION_KEY,
158        SCHEMA_REGISTRY_BACKOFF_FACTOR_KEY,
159        SCHEMA_REGISTRY_RETRIES_MAX_KEY,
160    ]
161    .iter()
162    .for_each(|key| {
163        try_consume_string_from_options(format_encode_options, key);
164    });
165}
166
167fn consume_string_from_options(
168    format_encode_options: &mut BTreeMap<String, String>,
169    key: &str,
170) -> Result<AstString> {
171    try_consume_string_from_options(format_encode_options, key).ok_or(RwError::from(ProtocolError(
172        format!("missing field {} in options", key),
173    )))
174}
175
176fn consume_aws_config_from_options(format_encode_options: &mut BTreeMap<String, String>) {
177    format_encode_options.retain(|key, _| !key.starts_with("aws."))
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum CreateSourceType {
182    SharedCdc,
183    /// e.g., shared Kafka source
184    SharedNonCdc,
185    NonShared,
186    /// create table with connector
187    Table,
188}
189
190impl CreateSourceType {
191    /// Note: shouldn't be used for `ALTER SOURCE`, since session variables should not affect existing source. We should respect the original type instead.
192    pub fn for_newly_created(
193        session: &SessionImpl,
194        with_properties: &impl WithPropertiesExt,
195    ) -> Self {
196        if with_properties.is_shareable_cdc_connector() {
197            CreateSourceType::SharedCdc
198        } else if with_properties.is_shareable_non_cdc_connector()
199            && session
200                .env()
201                .streaming_config()
202                .developer
203                .enable_shared_source
204            && session.config().streaming_use_shared_source()
205        {
206            CreateSourceType::SharedNonCdc
207        } else {
208            CreateSourceType::NonShared
209        }
210    }
211
212    pub fn for_replace(catalog: &SourceCatalog) -> Self {
213        if !catalog.info.is_shared() {
214            CreateSourceType::NonShared
215        } else if catalog.with_properties.is_shareable_cdc_connector() {
216            CreateSourceType::SharedCdc
217        } else {
218            CreateSourceType::SharedNonCdc
219        }
220    }
221
222    pub fn is_shared(&self) -> bool {
223        matches!(
224            self,
225            CreateSourceType::SharedCdc | CreateSourceType::SharedNonCdc
226        )
227    }
228}
229
230/// Bind columns from both source and sql defined.
231pub(crate) fn bind_all_columns(
232    format_encode: &FormatEncodeOptions,
233    cols_from_source: Option<Vec<ColumnCatalog>>,
234    cols_from_sql: Vec<ColumnCatalog>,
235    col_defs_from_sql: &[ColumnDef],
236    wildcard_idx: Option<usize>,
237    sql_column_strategy: SqlColumnStrategy,
238) -> Result<Vec<ColumnCatalog>> {
239    if let Some(cols_from_source) = cols_from_source {
240        // Need to check `col_defs` to see if a column is generated, as we haven't bind the
241        // `GeneratedColumnDesc` in `ColumnCatalog` yet.
242        let generated_cols_from_sql = cols_from_sql
243            .iter()
244            .filter(|c| {
245                col_defs_from_sql
246                    .iter()
247                    .find(|d| d.name.real_value() == c.name())
248                    .unwrap()
249                    .is_generated()
250            })
251            .cloned()
252            .collect_vec();
253
254        match sql_column_strategy {
255            // Ignore `cols_from_source`, follow `cols_from_sql` without checking.
256            SqlColumnStrategy::FollowUnchecked => {
257                assert!(
258                    wildcard_idx.is_none(),
259                    "wildcard still exists while strategy is Follows, not correctly purified?"
260                );
261                return Ok(cols_from_sql);
262            }
263
264            // Will merge `generated_cols_from_sql` into `cols_from_source`.
265            SqlColumnStrategy::Ignore => {}
266
267            SqlColumnStrategy::FollowChecked => {
268                let has_regular_cols_from_sql =
269                    generated_cols_from_sql.len() != cols_from_sql.len();
270
271                if has_regular_cols_from_sql {
272                    if wildcard_idx.is_some() {
273                        // (*, normal_column INT)
274                        return Err(RwError::from(NotSupported(
275                            "When there's a wildcard (\"*\"), \
276                             only generated columns are allowed in user-defined schema from SQL"
277                                .to_owned(),
278                            "Remove the non-generated columns".to_owned(),
279                        )));
280                    } else {
281                        // (normal_column INT)
282                        // Follow `cols_from_sql` with name & type checking.
283                        for col in &cols_from_sql {
284                            if generated_cols_from_sql.contains(col) {
285                                continue;
286                            }
287                            let Some(col_from_source) =
288                                cols_from_source.iter().find(|c| c.name() == col.name())
289                            else {
290                                return Err(RwError::from(ProtocolError(format!(
291                                    "Column \"{}\" is defined in SQL but not found in the source",
292                                    col.name()
293                                ))));
294                            };
295
296                            if col_from_source.data_type() != col.data_type() {
297                                return Err(RwError::from(ProtocolError(format!(
298                                    "Data type mismatch for column \"{}\". \
299                                     Defined in SQL as \"{}\", but found in the source as \"{}\"",
300                                    col.name(),
301                                    col.data_type(),
302                                    col_from_source.data_type()
303                                ))));
304                            }
305                        }
306                        return Ok(cols_from_sql);
307                    }
308                } else {
309                    if wildcard_idx.is_some() {
310                        // (*)
311                        // (*, generated_column INT)
312                        // Good, expand the wildcard later.
313                    } else {
314                        // ()
315                        // (generated_column INT)
316                        // Interpreted as if there's a wildcard for backward compatibility.
317                        // TODO: the behavior is not that consistent, making it impossible to ingest no
318                        //       columns from the source (though not useful in practice). Currently we
319                        //       just notice the user but we may want to interpret it as empty columns
320                        //       in the future.
321                        notice_to_user("\
322                            Neither wildcard (\"*\") nor regular (non-generated) columns appear in the user-defined schema from SQL. \
323                            For backward compatibility, all columns from the source will be included at the beginning. \
324                            For clarity, consider adding a wildcard (\"*\") to indicate where the columns from the source should be included, \
325                            or specifying the columns you want to include from the source.
326                        ");
327                    }
328                }
329            }
330        }
331
332        // In some cases the wildcard may be absent:
333        // - plan based on a purified SQL
334        // - interpret `()` as `(*)` for backward compatibility (see notice above)
335        // Default to 0 to expand the wildcard at the beginning.
336        let wildcard_idx = wildcard_idx.unwrap_or(0).min(generated_cols_from_sql.len());
337
338        // Merge `generated_cols_from_sql` with `cols_from_source`.
339        let mut merged_cols = generated_cols_from_sql;
340        let merged_cols_r = merged_cols.split_off(wildcard_idx);
341        merged_cols.extend(cols_from_source);
342        merged_cols.extend(merged_cols_r);
343
344        Ok(merged_cols)
345    } else {
346        if wildcard_idx.is_some() {
347            return Err(RwError::from(NotSupported(
348                "Wildcard in user-defined schema is only allowed when there exists columns from external schema".to_owned(),
349                "Remove the wildcard or use a source with external schema".to_owned(),
350            )));
351        }
352        let non_generated_sql_defined_columns = non_generated_sql_columns(col_defs_from_sql);
353
354        match (&format_encode.format, &format_encode.row_encode) {
355            (Format::DebeziumMongo, Encode::Json) => {
356                let strong_schema = format_encode
357                    .row_options
358                    .iter()
359                    .find(|k| k.name.real_value().to_lowercase() == CDC_MONGODB_STRONG_SCHEMA_KEY)
360                    .map(|k| matches!(k.value, SqlOptionValue::Value(Value::Boolean(true))))
361                    .unwrap_or(false);
362
363                // strong schema requires a '_id' column at the first position with a specific type
364                if strong_schema {
365                    let (_, id_column) = non_generated_sql_defined_columns
366                        .iter()
367                        .enumerate()
368                        .find(|(idx, col)| *idx == 0 && col.name.real_value() == "_id")
369                        .ok_or_else(|| {
370                            RwError::from(ProtocolError(
371                                "The `_id` column of the source with row format DebeziumMongoJson must be defined as the first column in SQL".to_owned(),
372                            ))
373                        })?;
374
375                    let id_data_type = bind_data_type(id_column.data_type.as_ref().unwrap())?;
376                    if !matches!(
377                        id_data_type,
378                        DataType::Varchar | DataType::Int32 | DataType::Int64 | DataType::Jsonb
379                    ) {
380                        return Err(RwError::from(ProtocolError(
381                            "the `_id` column of the source with row format DebeziumMongoJson must be [Jsonb | Varchar | Int32 | Int64]".to_owned(),
382                        )));
383                    }
384
385                    let mut columns = Vec::with_capacity(non_generated_sql_defined_columns.len());
386                    columns.push(
387                        // id column
388                        ColumnCatalog {
389                            column_desc: ColumnDesc::named("_id", 0.into(), id_data_type),
390                            is_hidden: false,
391                        },
392                    );
393
394                    // bind rest of the columns
395                    for (idx, col) in non_generated_sql_defined_columns
396                        .into_iter()
397                        // skip the first column
398                        .skip(1)
399                        .enumerate()
400                    {
401                        columns.push(ColumnCatalog {
402                            column_desc: ColumnDesc::named(
403                                col.name.real_value(),
404                                (idx as i32).into(),
405                                bind_data_type(col.data_type.as_ref().unwrap())?,
406                            ),
407                            is_hidden: false,
408                        });
409                    }
410
411                    return Ok(columns);
412                }
413
414                let mut columns = vec![
415                    ColumnCatalog {
416                        column_desc: ColumnDesc::named("_id", 0.into(), DataType::Varchar),
417                        is_hidden: false,
418                    },
419                    ColumnCatalog {
420                        column_desc: ColumnDesc::named("payload", 0.into(), DataType::Jsonb),
421                        is_hidden: false,
422                    },
423                ];
424
425                if non_generated_sql_defined_columns.len() != 2
426                    || non_generated_sql_defined_columns[0].name.real_value() != columns[0].name()
427                    || non_generated_sql_defined_columns[1].name.real_value() != columns[1].name()
428                {
429                    return Err(RwError::from(ProtocolError(
430                        "the not generated columns of the source with row format DebeziumMongoJson
431        must be (_id [Jsonb | Varchar | Int32 | Int64], payload jsonb)."
432                            .to_owned(),
433                    )));
434                }
435                // ok to unwrap since it was checked at `bind_sql_columns`
436                let key_data_type = bind_data_type(
437                    non_generated_sql_defined_columns[0]
438                        .data_type
439                        .as_ref()
440                        .unwrap(),
441                )?;
442                match key_data_type {
443                    DataType::Jsonb | DataType::Varchar | DataType::Int32 | DataType::Int64 => {
444                        columns[0].column_desc.data_type = key_data_type;
445                    }
446                    _ => {
447                        return Err(RwError::from(ProtocolError(
448                            "the `_id` column of the source with row format DebeziumMongoJson
449        must be [Jsonb | Varchar | Int32 | Int64]"
450                                .to_owned(),
451                        )));
452                    }
453                }
454
455                // ok to unwrap since it was checked at `bind_sql_columns`
456                let value_data_type = bind_data_type(
457                    non_generated_sql_defined_columns[1]
458                        .data_type
459                        .as_ref()
460                        .unwrap(),
461                )?;
462                if !matches!(value_data_type, DataType::Jsonb) {
463                    return Err(RwError::from(ProtocolError(
464                        "the `payload` column of the source with row format DebeziumMongoJson
465        must be Jsonb datatype"
466                            .to_owned(),
467                    )));
468                }
469                Ok(columns)
470            }
471            (Format::Plain, Encode::Bytes) => {
472                let err = Err(RwError::from(ProtocolError(
473                    "ENCODE BYTES only accepts one BYTEA type column".to_owned(),
474                )));
475                if non_generated_sql_defined_columns.len() == 1 {
476                    // ok to unwrap `data_type`` since it was checked at `bind_sql_columns`
477                    let col_data_type = bind_data_type(
478                        non_generated_sql_defined_columns[0]
479                            .data_type
480                            .as_ref()
481                            .unwrap(),
482                    )?;
483                    if col_data_type == DataType::Bytea {
484                        Ok(cols_from_sql)
485                    } else {
486                        err
487                    }
488                } else {
489                    err
490                }
491            }
492            (_, _) => Ok(cols_from_sql),
493        }
494    }
495}
496
497/// TODO: perhaps put the hint in notice is better. The error message format might be not that reliable.
498fn hint_format_encode(format_encode: &FormatEncodeOptions) -> String {
499    format!(
500        r#"Hint: For FORMAT {0} ENCODE {1}, INCLUDE KEY must be specified and the key column must be used as primary key.
501example:
502    CREATE TABLE <table_name> ( PRIMARY KEY ([rw_key | <key_name>]) )
503    INCLUDE KEY [AS <key_name>]
504    WITH (...)
505    FORMAT {0} ENCODE {1}{2}
506"#,
507        format_encode.format,
508        format_encode.row_encode,
509        if format_encode.row_encode == Encode::Json || format_encode.row_encode == Encode::Bytes {
510            "".to_owned()
511        } else {
512            " (...)".to_owned()
513        }
514    )
515}
516
517/// Bind column from source. Add key column to table columns if necessary.
518/// Return `pk_names`.
519pub(crate) async fn bind_source_pk(
520    format_encode: &FormatEncodeOptions,
521    source_info: &StreamSourceInfo,
522    columns: &mut [ColumnCatalog],
523    sql_defined_pk_names: Vec<String>,
524    with_properties: &WithOptionsSecResolved,
525) -> Result<Vec<String>> {
526    let sql_defined_pk = !sql_defined_pk_names.is_empty();
527    let include_key_column_name: Option<String> = {
528        // iter columns to check if contains additional columns from key part
529        // return the key column names if exists
530        columns.iter().find_map(|catalog| {
531            if matches!(
532                catalog.column_desc.additional_column.column_type,
533                Some(AdditionalColumnType::Key(_))
534            ) {
535                Some(catalog.name().to_owned())
536            } else {
537                None
538            }
539        })
540    };
541    let additional_column_names = columns
542        .iter()
543        .filter_map(|col| {
544            if col.column_desc.additional_column.column_type.is_some() {
545                Some(col.name().to_owned())
546            } else {
547                None
548            }
549        })
550        .collect_vec();
551
552    let res = match (&format_encode.format, &format_encode.row_encode) {
553        (Format::Native, Encode::Native) | (Format::None, Encode::None) | (Format::Plain, _) => {
554            sql_defined_pk_names
555        }
556
557        // For all Upsert formats, we only accept one and only key column as primary key.
558        // Additional KEY columns must be set in this case and must be primary key.
559        (Format::Upsert, Encode::Json | Encode::Avro | Encode::Protobuf) => {
560            if let Some(ref key_column_name) = include_key_column_name
561                && sql_defined_pk
562            {
563                // pk is set. check if it's valid
564
565                // the column name have been converted to real value in `handle_addition_columns`
566                // so we don't ignore ascii case here
567                if sql_defined_pk_names.len() != 1
568                    || !key_column_name.eq(sql_defined_pk_names[0].as_str())
569                {
570                    return Err(RwError::from(ProtocolError(format!(
571                        "Only \"{}\" can be used as primary key\n\n{}",
572                        key_column_name,
573                        hint_format_encode(format_encode)
574                    ))));
575                }
576                sql_defined_pk_names
577            } else {
578                // pk not set, or even key not included
579                return if let Some(include_key_column_name) = include_key_column_name {
580                    Err(RwError::from(ProtocolError(format!(
581                        "Primary key must be specified to {}\n\n{}",
582                        include_key_column_name,
583                        hint_format_encode(format_encode)
584                    ))))
585                } else {
586                    Err(RwError::from(ProtocolError(format!(
587                        "INCLUDE KEY clause not set\n\n{}",
588                        hint_format_encode(format_encode)
589                    ))))
590                };
591            }
592        }
593
594        (Format::Debezium, Encode::Json) => {
595            if !additional_column_names.is_empty() {
596                return Err(RwError::from(ProtocolError(format!(
597                    "FORMAT DEBEZIUM forbids additional columns, but got {:?}",
598                    additional_column_names
599                ))));
600            }
601            if !sql_defined_pk {
602                return Err(RwError::from(ProtocolError(
603                    "Primary key must be specified when creating source with FORMAT DEBEZIUM."
604                        .to_owned(),
605                )));
606            }
607            sql_defined_pk_names
608        }
609        (Format::Debezium, Encode::Avro) => {
610            if !additional_column_names.is_empty() {
611                return Err(RwError::from(ProtocolError(format!(
612                    "FORMAT DEBEZIUM forbids additional columns, but got {:?}",
613                    additional_column_names
614                ))));
615            }
616            if sql_defined_pk {
617                sql_defined_pk_names
618            } else {
619                let pk_names =
620                    extract_debezium_avro_table_pk_columns(source_info, with_properties).await?;
621                // extract pk(s) from schema registry
622                for pk_name in &pk_names {
623                    columns
624                        .iter()
625                        .find(|c: &&ColumnCatalog| c.name().eq(pk_name))
626                        .ok_or_else(|| {
627                            RwError::from(ProtocolError(format!(
628                                "avro's key column {} not exists in avro's row schema",
629                                pk_name
630                            )))
631                        })?;
632                }
633                pk_names
634            }
635        }
636        (Format::DebeziumMongo, Encode::Json) => {
637            if sql_defined_pk {
638                sql_defined_pk_names
639            } else {
640                vec!["_id".to_owned()]
641            }
642        }
643
644        (Format::Maxwell, Encode::Json) => {
645            if !additional_column_names.is_empty() {
646                return Err(RwError::from(ProtocolError(format!(
647                    "FORMAT MAXWELL forbids additional columns, but got {:?}",
648                    additional_column_names
649                ))));
650            }
651            if !sql_defined_pk {
652                return Err(RwError::from(ProtocolError(
653    "Primary key must be specified when creating source with FORMAT MAXWELL ENCODE JSON.".to_owned(),
654    )));
655            }
656            sql_defined_pk_names
657        }
658
659        (Format::Canal, Encode::Json) => {
660            if !additional_column_names.is_empty() {
661                return Err(RwError::from(ProtocolError(format!(
662                    "FORMAT CANAL forbids additional columns, but got {:?}",
663                    additional_column_names
664                ))));
665            }
666            if !sql_defined_pk {
667                return Err(RwError::from(ProtocolError(
668    "Primary key must be specified when creating source with FORMAT CANAL ENCODE JSON.".to_owned(),
669    )));
670            }
671            sql_defined_pk_names
672        }
673        (format, encoding) => {
674            return Err(RwError::from(ProtocolError(format!(
675                "Unknown combination {:?} {:?}",
676                format, encoding
677            ))));
678        }
679    };
680    Ok(res)
681}
682
683pub(super) fn bind_source_watermark(
684    session: &SessionImpl,
685    name: String,
686    source_watermarks: Vec<SourceWatermark>,
687    column_catalogs: &[ColumnCatalog],
688) -> Result<Vec<WatermarkDesc>> {
689    let mut binder = Binder::new_for_ddl(session);
690    binder.bind_columns_to_context(name.clone(), column_catalogs)?;
691
692    let mut session_tz = SessionTimezone::new(session.config().timezone());
693
694    let watermark_descs = source_watermarks
695        .into_iter()
696        .map(|source_watermark| {
697            let col_name = source_watermark.column.real_value();
698            let watermark_idx = binder.get_column_binding_index(name.clone(), &col_name)?;
699
700            let expr = binder.bind_expr(&source_watermark.expr)?;
701            // Apply session timezone rewrite so that timestamptz +/- interval operations
702            // are transformed into the timezone-aware variant (e.g. `subtract_with_time_zone`).
703            // Without this, intervals containing days/months would fail at runtime.
704            let expr = session_tz.rewrite_expr(expr);
705            let watermark_col_type = column_catalogs[watermark_idx].data_type();
706            let watermark_expr_type = &expr.return_type();
707            if watermark_col_type != watermark_expr_type {
708                Err(RwError::from(ErrorCode::BindError(
709                    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),
710                )))
711            } else {
712                let expr_proto = expr.to_expr_proto();
713                Ok::<_, RwError>(WatermarkDesc {
714                    watermark_idx: watermark_idx as u32,
715                    expr: Some(expr_proto),
716                    with_ttl: source_watermark.with_ttl,
717                })
718            }
719        })
720        .try_collect()?;
721    Ok(watermark_descs)
722}
723
724/// Performs early stage checking in frontend to see if the schema of the given `columns` is
725/// compatible with the connector extracted from the properties.
726///
727/// One should only call this function after all properties of all columns are resolved, like
728/// generated column descriptors.
729pub(super) fn check_format_encode(
730    props: &WithOptionsSecResolved,
731    row_id_index: Option<usize>,
732    columns: &[ColumnCatalog],
733) -> Result<()> {
734    let Some(connector) = props.get_connector() else {
735        return Ok(());
736    };
737
738    if connector == NEXMARK_CONNECTOR {
739        check_nexmark_schema(props, row_id_index, columns)
740    } else {
741        Ok(())
742    }
743}
744
745pub fn bind_connector_props(
746    handler_args: &HandlerArgs,
747    format_encode: &FormatEncodeOptions,
748    is_create_source: bool,
749) -> Result<(WithOptions, SourceRefreshMode)> {
750    let mut with_properties = handler_args.with_options.clone().into_connector_props();
751    validate_compatibility(format_encode, &mut with_properties)?;
752    let refresh_mode = {
753        let refresh_mode = resolve_source_refresh_mode_in_with_option(&mut with_properties)?;
754        if is_create_source && refresh_mode.is_some() {
755            return Err(RwError::from(ProtocolError(
756                "`refresh_mode` only supported for CREATE TABLE".to_owned(),
757            )));
758        }
759
760        refresh_mode.unwrap_or(SourceRefreshMode {
761            refresh_mode: Some(RefreshMode::Streaming(SourceRefreshModeStreaming {})),
762        })
763    };
764
765    let create_cdc_source_job = with_properties.is_shareable_cdc_connector();
766
767    if !is_create_source && with_properties.is_shareable_cdc_connector() {
768        return Err(RwError::from(ProtocolError(format!(
769            "directly creating a CDC table for connector {} is no longer supported; \
770             please `CREATE SOURCE` to create a shared CDC source first, \
771             then `CREATE TABLE ... FROM <source> TABLE '<database>.<table>'`",
772            with_properties.get_connector().unwrap(),
773        ))));
774    }
775    if is_create_source && create_cdc_source_job {
776        if let Some(value) = with_properties.get(AUTO_SCHEMA_CHANGE_KEY)
777            && value.parse::<bool>().map_err(|_| {
778                ErrorCode::InvalidInputSyntax(format!(
779                    "invalid value of '{}' option",
780                    AUTO_SCHEMA_CHANGE_KEY
781                ))
782            })?
783        {
784            Feature::CdcAutoSchemaChange.check_available()?;
785        }
786
787        // set connector to backfill mode
788        with_properties.insert(CDC_SNAPSHOT_MODE_KEY.into(), CDC_SNAPSHOT_BACKFILL.into());
789        // enable cdc sharing mode, which will capture all tables in the given `database.name`
790        with_properties.insert(CDC_SHARING_MODE_KEY.into(), "true".into());
791        // enable transactional cdc
792        if with_properties.enable_transaction_metadata() {
793            with_properties.insert(CDC_TRANSACTIONAL_KEY.into(), "true".into());
794        }
795        // Only set CDC_WAIT_FOR_STREAMING_START_TIMEOUT if not already specified by user.
796        if !with_properties.contains_key(CDC_WAIT_FOR_STREAMING_START_TIMEOUT) {
797            with_properties.insert(
798                CDC_WAIT_FOR_STREAMING_START_TIMEOUT.into(),
799                handler_args
800                    .session
801                    .config()
802                    .cdc_source_wait_streaming_start_timeout()
803                    .to_string(),
804            );
805        }
806    }
807    if with_properties.is_mysql_cdc_connector() {
808        // Generate a random server id for mysql cdc source if needed
809        // `server.id` (in the range from 1 to 2^32 - 1). This value MUST be unique across whole replication
810        // group (that is, different from any other server id being used by any master or slave)
811        with_properties
812            .entry("server.id".to_owned())
813            .or_insert(rand::rng().random_range(1..u32::MAX).to_string());
814    }
815    Ok((with_properties, refresh_mode))
816}
817
818fn must_wait_cdc_offset_before_report(with_properties: &WithOptions) -> bool {
819    matches!(
820        with_properties.get_connector().as_deref(),
821        Some(MYSQL_CDC_CONNECTOR) | Some(SQL_SERVER_CDC_CONNECTOR)
822    )
823}
824
825/// When the schema can be inferred from external system (like schema registry),
826/// how to handle the regular columns (i.e., non-generated) defined in SQL?
827pub enum SqlColumnStrategy {
828    /// Follow all columns defined in SQL, ignore the columns from external system.
829    /// This ensures that no accidental side effect will change the schema.
830    ///
831    /// This is the behavior when re-planning the target table of `SINK INTO`.
832    FollowUnchecked,
833
834    /// Follow all column defined in SQL, check the columns from external system with name & type.
835    /// This ensures that no accidental side effect will change the schema.
836    ///
837    /// This is the behavior when creating a new table or source with a resolvable schema;
838    /// adding, or dropping columns on that table.
839    // TODO(purify): `ALTER SOURCE` currently has its own code path and does not check.
840    FollowChecked,
841
842    /// Merge the generated columns defined in SQL and columns from external system. If there
843    /// are also regular columns defined in SQL, ignore silently.
844    ///
845    /// This is the behavior when `REFRESH SCHEMA` atop the purified SQL.
846    Ignore,
847}
848
849/// Reject user-declared `VARIANT` columns (including nested) on binding paths whose ingest
850/// pipeline cannot produce variant values. `context` names the path in the error message.
851pub(crate) fn reject_variant_columns(columns: &[ColumnCatalog], context: &str) -> Result<()> {
852    if let Some(col) = columns.iter().find(|c| c.data_type().contains_variant()) {
853        return Err(RwError::from(NotSupported(
854            format!(
855                "VARIANT column \"{}\" is not supported {context} yet",
856                col.name()
857            ),
858            format!("VARIANT columns are not supported {context} yet"),
859        )));
860    }
861    Ok(())
862}
863
864/// Reject user-declared `VARIANT` columns when the `FORMAT ... ENCODE ...` is handled by a
865/// row-encode parser or native generator, none of which can produce variant values.
866///
867/// Not gated: connector-native schemas (`ENCODE NONE`, e.g. iceberg) infer their own schema, and
868/// `ENCODE PARQUET` reads variant columns via the Parquet Variant extension type.
869fn reject_variant_columns_for_unsupported_encoding(
870    format_encode: &FormatEncodeOptions,
871    columns_from_sql: &[ColumnCatalog],
872) -> Result<()> {
873    if matches!(
874        (&format_encode.format, &format_encode.row_encode),
875        (Format::None, Encode::None) | (_, Encode::Parquet)
876    ) {
877        return Ok(());
878    }
879    reject_variant_columns(columns_from_sql, "for this source encoding")
880}
881
882/// Entrypoint for binding source connector.
883/// Common logic shared by `CREATE SOURCE` and `CREATE TABLE`.
884#[expect(clippy::too_many_arguments)]
885pub async fn bind_create_source_or_table_with_connector(
886    handler_args: HandlerArgs,
887    full_name: ObjectName,
888    format_encode: FormatEncodeOptions,
889    with_properties: WithOptions,
890    sql_columns_defs: &[ColumnDef],
891    constraints: Vec<TableConstraint>,
892    wildcard_idx: Option<usize>,
893    source_watermarks: Vec<SourceWatermark>,
894    columns_from_resolve_source: Option<Vec<ColumnCatalog>>,
895    source_info: StreamSourceInfo,
896    include_column_options: IncludeOption,
897    col_id_gen: &mut ColumnIdGenerator,
898    create_source_type: CreateSourceType,
899    source_rate_limit: Option<u32>,
900    sql_column_strategy: SqlColumnStrategy,
901    refresh_mode: SourceRefreshMode,
902) -> Result<SourceCatalog> {
903    let session = &handler_args.session;
904    let db_name: &str = &session.database();
905    let (schema_name, source_name) = Binder::resolve_schema_qualified_name(db_name, &full_name)?;
906    let (database_id, schema_id) =
907        session.get_database_and_schema_id_for_create(schema_name.clone())?;
908
909    let is_create_source = create_source_type != CreateSourceType::Table;
910
911    if is_create_source {
912        // reject refreshable batch source
913        if with_properties.is_batch_connector() {
914            return Err(ErrorCode::BindError(
915            "can't CREATE SOURCE with refreshable batch connector\n\nHint: use CREATE TABLE instead"
916                .to_owned(),
917        )
918        .into());
919        }
920
921        match format_encode.format {
922            // reject unsupported formats for CREATE SOURCE
923            Format::Debezium | Format::DebeziumMongo | Format::Maxwell | Format::Canal => {
924                return Err(ErrorCode::BindError(format!(
925                    "can't CREATE SOURCE with FORMAT {}.\n\nHint: use CREATE TABLE instead\n\n{}",
926                    format_encode.format,
927                    hint_format_encode(&format_encode)
928                ))
929                .into());
930            }
931            // hint limitations for some other formats
932            Format::Upsert => {
933                notice_to_user(format!(
934                    "Streaming queries on sources with `FORMAT {}` may have limitations. If your query isn't supported, consider using `CREATE TABLE` instead.",
935                    format_encode.format
936                ));
937            }
938            _ => {}
939        }
940    }
941
942    let sql_pk_names = bind_sql_pk_names(sql_columns_defs, bind_table_constraints(&constraints)?)?;
943
944    if with_properties.is_iceberg_connector() {
945        if is_create_source && !sql_pk_names.is_empty() {
946            return Err(ErrorCode::NotSupported(
947                "PRIMARY KEY is not supported for Iceberg CREATE SOURCE in continuous ingestion mode."
948                    .to_owned(),
949                "Iceberg streaming ingestion only supports append-only sources. Remove the PRIMARY KEY clause."
950                    .to_owned(),
951            )
952            .into());
953        }
954
955        // FIXME: ideally we can support it, but current way of handling iceberg additional columns
956        // are problematic. They are treated as normal user columns, so they will be lost if we
957        // allow user to specify columns. See `extract_iceberg_columns`.
958        if !sql_columns_defs.is_empty() {
959            return Err(RwError::from(InvalidInputSyntax(
960                r#"Schema is automatically inferred for iceberg source and should not be specified
961
962HINT: use `CREATE SOURCE <name> WITH (...)` instead of `CREATE SOURCE <name> (<columns>) WITH (...)`."#.to_owned(),
963            )));
964        }
965    }
966
967    // Same for ADBC Snowflake connector - schema is automatically inferred
968    if with_properties.is_batch_connector()
969        && with_properties
970            .get(UPSTREAM_SOURCE_KEY)
971            .is_some_and(|s| s.eq_ignore_ascii_case(ADBC_SNOWFLAKE_CONNECTOR))
972        && !sql_columns_defs.is_empty()
973    {
974        return Err(RwError::from(InvalidInputSyntax(
975            r#"Schema is automatically inferred for ADBC Snowflake source and should not be specified
976
977HINT: use `CREATE TABLE <name> WITH (...)` instead of `CREATE TABLE <name> (<columns>) WITH (...)`."#.to_owned(),
978        )));
979    }
980    let columns_from_sql = bind_sql_columns(sql_columns_defs, false)?;
981
982    reject_variant_columns_for_unsupported_encoding(&format_encode, &columns_from_sql)?;
983
984    let mut columns = bind_all_columns(
985        &format_encode,
986        columns_from_resolve_source,
987        columns_from_sql,
988        sql_columns_defs,
989        wildcard_idx,
990        sql_column_strategy,
991    )?;
992
993    // add additional columns before bind pk, because `format upsert` requires the key column
994    handle_addition_columns(
995        Some(&format_encode),
996        &with_properties,
997        include_column_options,
998        &mut columns,
999        false,
1000    )?;
1001
1002    if columns.is_empty() {
1003        return Err(RwError::from(ProtocolError(
1004            "Schema definition is required, either from SQL or schema registry.".to_owned(),
1005        )));
1006    }
1007
1008    // compatible with the behavior that add a hidden column `_rw_kafka_timestamp` to each message from Kafka source
1009    if is_create_source {
1010        // must behind `handle_addition_columns`
1011        check_and_add_timestamp_column(&with_properties, &mut columns);
1012
1013        // For shared sources, we will include partition and offset cols in the SourceExecutor's *output*, to be used by the SourceBackfillExecutor.
1014        // For shared CDC source, the schema is different. See ColumnCatalog::debezium_cdc_source_cols(), CDC_BACKFILL_TABLE_ADDITIONAL_COLUMNS
1015        if create_source_type == CreateSourceType::SharedNonCdc {
1016            let (columns_exist, additional_columns) = source_add_partition_offset_cols(
1017                &columns,
1018                &with_properties.get_connector().unwrap(),
1019                true, // col_id filled below at col_id_gen.generate
1020            );
1021            for (existed, c) in columns_exist.into_iter().zip_eq_fast(additional_columns) {
1022                if !existed {
1023                    columns.push(ColumnCatalog::hidden(c));
1024                }
1025            }
1026        }
1027    }
1028
1029    // resolve privatelink connection for Kafka
1030    let mut with_properties = with_properties;
1031    resolve_privatelink_in_with_option(&mut with_properties)?;
1032
1033    // check the system parameter `enforce_secret`
1034    if session
1035        .env()
1036        .system_params_manager()
1037        .get_params()
1038        .load()
1039        .enforce_secret()
1040        && Feature::SecretManagement.check_available().is_ok()
1041    {
1042        // check enforce using secret for some props on cloud
1043        ConnectorProperties::enforce_secret_source(&with_properties)?;
1044    }
1045
1046    let (with_properties, connection_type, connector_conn_ref) =
1047        resolve_connection_ref_and_secret_ref(
1048            with_properties,
1049            session,
1050            Some(TelemetryDatabaseObject::Source),
1051        )?;
1052    ensure_connection_type_allowed(connection_type, &SOURCE_ALLOWED_CONNECTION_CONNECTOR)?;
1053
1054    // if not using connection, we don't need to check connector match connection type
1055    if !matches!(connection_type, PbConnectionType::Unspecified) {
1056        let Some(connector) = with_properties.get_connector() else {
1057            return Err(RwError::from(ProtocolError(format!(
1058                "missing field '{}' in WITH clause",
1059                UPSTREAM_SOURCE_KEY
1060            ))));
1061        };
1062        check_connector_match_connection_type(connector.as_str(), &connection_type)?;
1063    }
1064
1065    let pk_names = bind_source_pk(
1066        &format_encode,
1067        &source_info,
1068        &mut columns,
1069        sql_pk_names,
1070        &with_properties,
1071    )
1072    .await?;
1073
1074    // User may specify a generated or additional column with the same name as one from the external schema.
1075    // Ensure duplicated column names are handled here.
1076    if let Some(duplicated_name) = columns.iter().map(|c| c.name()).duplicates().next() {
1077        return Err(ErrorCode::InvalidInputSyntax(format!(
1078            "column \"{}\" specified more than once",
1079            duplicated_name
1080        ))
1081        .into());
1082    }
1083
1084    // XXX: why do we use col_id_gen here? It doesn't seem to be very necessary.
1085    for c in &mut columns {
1086        let original_data_type = c.data_type().clone();
1087        col_id_gen.generate(c)?;
1088        // TODO: Now we restore the data type for `CREATE SOURCE`, so that keep the nested field id unset.
1089        //       This behavior is inconsistent with `CREATE TABLE`, and should be fixed once we refactor
1090        //       `ALTER SOURCE` to also use `ColumnIdGenerator` in the future.
1091        if is_create_source {
1092            c.column_desc.data_type = original_data_type;
1093        }
1094    }
1095    debug_assert_column_ids_distinct(&columns);
1096
1097    let (mut columns, pk_col_ids, row_id_index) =
1098        bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
1099
1100    let watermark_descs =
1101        bind_source_watermark(session, source_name.clone(), source_watermarks, &columns)?;
1102    // TODO(yuhao): allow multiple watermark on source.
1103    assert!(watermark_descs.len() <= 1);
1104    if is_create_source && watermark_descs.iter().any(|d| d.with_ttl) {
1105        return Err(ErrorCode::NotSupported(
1106            "WITH TTL is not supported in WATERMARK clause for CREATE SOURCE.".to_owned(),
1107            "Use `CREATE TABLE ... WATERMARK ... WITH TTL` instead.".to_owned(),
1108        )
1109        .into());
1110    }
1111
1112    let append_only = row_id_index.is_some();
1113    if is_create_source && !append_only && !watermark_descs.is_empty() {
1114        return Err(ErrorCode::NotSupported(
1115            "Defining watermarks on source requires the source connector to be append only."
1116                .to_owned(),
1117            "Use the key words `FORMAT PLAIN`".to_owned(),
1118        )
1119        .into());
1120    }
1121
1122    bind_sql_column_constraints(
1123        session,
1124        source_name.clone(),
1125        &mut columns,
1126        // TODO(st1page): pass the ref
1127        sql_columns_defs,
1128        &pk_col_ids,
1129    )?;
1130    check_format_encode(&with_properties, row_id_index, &columns)?;
1131
1132    let definition = handler_args.normalized_sql.clone();
1133
1134    let associated_table_id = if is_create_source {
1135        None
1136    } else {
1137        Some(TableId::placeholder())
1138    };
1139    let source = SourceCatalog {
1140        id: SourceId::placeholder(),
1141        name: source_name,
1142        schema_id,
1143        database_id,
1144        columns,
1145        pk_col_ids,
1146        append_only,
1147        owner: session.user_id(),
1148        info: source_info,
1149        row_id_index,
1150        with_properties,
1151        watermark_descs,
1152        associated_table_id,
1153        definition,
1154        connection_id: connector_conn_ref,
1155        created_at_epoch: None,
1156        initialized_at_epoch: None,
1157        version: INITIAL_SOURCE_VERSION_ID,
1158        created_at_cluster_version: None,
1159        initialized_at_cluster_version: None,
1160        rate_limit: source_rate_limit,
1161        refresh_mode: Some(refresh_mode),
1162    };
1163    Ok(source)
1164}
1165
1166pub async fn handle_create_source(
1167    mut handler_args: HandlerArgs,
1168    stmt: CreateSourceStatement,
1169) -> Result<RwPgResponse> {
1170    let session = handler_args.session.clone();
1171
1172    if let Either::Right(resp) = session.check_relation_name_duplicated(
1173        stmt.source_name.clone(),
1174        StatementType::CREATE_SOURCE,
1175        stmt.if_not_exists,
1176    )? {
1177        return Ok(resp);
1178    }
1179
1180    if stmt
1181        .source_name
1182        .base_name()
1183        .starts_with(ICEBERG_SOURCE_PREFIX)
1184    {
1185        return Err(RwError::from(InvalidInputSyntax(format!(
1186            "Source name cannot start with reserved prefix '{}'",
1187            ICEBERG_SOURCE_PREFIX
1188        ))));
1189    }
1190
1191    if stmt.cdc_table_info.is_some() {
1192        return handle_create_cdc_table_source(handler_args, stmt).await;
1193    }
1194
1195    let overwrite_options = OverwriteOptions::new(&mut handler_args);
1196
1197    if handler_args.with_options.is_empty() {
1198        return Err(RwError::from(InvalidInputSyntax(
1199            "missing WITH clause".to_owned(),
1200        )));
1201    }
1202
1203    if overwrite_options.source_rate_limit == Some(0)
1204        && must_wait_cdc_offset_before_report(&handler_args.with_options)
1205    {
1206        let connector = handler_args.with_options.get_connector().unwrap();
1207        return Err(RwError::from(ErrorCode::InvalidParameterValue(format!(
1208            "`source_rate_limit` cannot be 0 when creating a `{connector}` source because source creation must wait for the initial CDC offset."
1209        ))));
1210    }
1211
1212    let format_encode = stmt.format_encode.into_v2_with_warning();
1213    let (with_properties, refresh_mode) =
1214        bind_connector_props(&handler_args, &format_encode, true)?;
1215    if let Some(connector) = with_properties.get_connector() {
1216        ensure_local_fs_connector_allowed(&session, &connector)?;
1217    }
1218
1219    let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
1220    let (columns_from_resolve_source, source_info) = bind_columns_from_source(
1221        &session,
1222        &format_encode,
1223        Either::Left(&with_properties),
1224        create_source_type,
1225    )
1226    .await?;
1227    let mut col_id_gen = ColumnIdGenerator::new_initial();
1228
1229    if stmt.columns.iter().any(|col| {
1230        col.options
1231            .iter()
1232            .any(|def| matches!(def.option, ColumnOption::NotNull))
1233    }) {
1234        return Err(RwError::from(InvalidInputSyntax(
1235            "NOT NULL constraint is not supported in source schema".to_owned(),
1236        )));
1237    }
1238
1239    let source_catalog = bind_create_source_or_table_with_connector(
1240        handler_args.clone(),
1241        stmt.source_name,
1242        format_encode,
1243        with_properties,
1244        &stmt.columns,
1245        stmt.constraints,
1246        stmt.wildcard_idx,
1247        stmt.source_watermarks,
1248        columns_from_resolve_source,
1249        source_info,
1250        stmt.include_column_options,
1251        &mut col_id_gen,
1252        create_source_type,
1253        overwrite_options.source_rate_limit,
1254        SqlColumnStrategy::FollowChecked,
1255        refresh_mode,
1256    )
1257    .await?;
1258
1259    // If it is a temporary source, put it into SessionImpl.
1260    if stmt.temporary {
1261        if session.get_temporary_source(&source_catalog.name).is_some() {
1262            return Err(CatalogError::duplicated("source", source_catalog.name.clone()).into());
1263        }
1264        session.create_temporary_source(source_catalog);
1265        return Ok(PgResponse::empty_result(StatementType::CREATE_SOURCE));
1266    }
1267
1268    let source = source_catalog.to_prost();
1269
1270    let catalog_writer = session.catalog_writer()?;
1271
1272    if create_source_type.is_shared() {
1273        let graph = generate_stream_graph_for_source(handler_args, source_catalog)?;
1274        catalog_writer
1275            .create_source(source, Some(graph), stmt.if_not_exists)
1276            .await?;
1277    } else {
1278        // For other sources we don't create a streaming job
1279        catalog_writer
1280            .create_source(source, None, stmt.if_not_exists)
1281            .await?;
1282    }
1283
1284    Ok(PgResponse::empty_result(StatementType::CREATE_SOURCE))
1285}
1286
1287async fn handle_create_cdc_table_source(
1288    handler_args: HandlerArgs,
1289    stmt: CreateSourceStatement,
1290) -> Result<RwPgResponse> {
1291    let session = handler_args.session.clone();
1292    let cdc_table_info = stmt.cdc_table_info.as_ref().expect("checked by caller");
1293
1294    if stmt.temporary {
1295        return Err(ErrorCode::NotSupported(
1296            "temporary CDC table sources are not supported".to_owned(),
1297            "Remove the TEMPORARY clause".to_owned(),
1298        )
1299        .into());
1300    }
1301
1302    if handler_args.with_options.len() != 1
1303        || !handler_args
1304            .with_options
1305            .value_eq_ignore_case(CDC_BACKFILL_ENABLE_KEY, "false")
1306        || !handler_args.with_options.secret_ref().is_empty()
1307        || !handler_args.with_options.connection_ref().is_empty()
1308    {
1309        return Err(ErrorCode::InvalidInputSyntax(
1310            "CDC table sources currently require exactly `WITH (snapshot = 'false')`".to_owned(),
1311        )
1312        .into());
1313    }
1314
1315    if !stmt.source_watermarks.is_empty() {
1316        return Err(ErrorCode::NotSupported(
1317            "watermarks on CDC table sources are not supported".to_owned(),
1318            "Remove the WATERMARK clause".to_owned(),
1319        )
1320        .into());
1321    }
1322    if stmt.columns.iter().any(|column| column.is_generated()) {
1323        return Err(ErrorCode::NotSupported(
1324            "generated columns on CDC table sources are not supported".to_owned(),
1325            "Define generated expressions in the downstream streaming query".to_owned(),
1326        )
1327        .into());
1328    }
1329    for column in &stmt.columns {
1330        for option in &column.options {
1331            if matches!(
1332                option.option,
1333                ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. }
1334            ) {
1335                return Err(ErrorCode::NotSupported(
1336                    "default values on CDC table sources are not supported".to_owned(),
1337                    "Remove the default value expression".to_owned(),
1338                )
1339                .into());
1340            }
1341        }
1342    }
1343
1344    sanity_check_for_table_on_cdc_source(
1345        false,
1346        &stmt.columns,
1347        &stmt.wildcard_idx,
1348        &stmt.constraints,
1349        &stmt.source_watermarks,
1350    )?;
1351    not_null_check_for_cdc_table(&stmt.wildcard_idx, &stmt.columns)?;
1352
1353    let db_name = session.database();
1354    let user_name = session.user_name();
1355    let search_path = session.config().search_path();
1356    let (schema_name, source_name) =
1357        Binder::resolve_schema_qualified_name(&db_name, &stmt.source_name)?;
1358    let (database_id, schema_id) =
1359        session.get_database_and_schema_id_for_create(schema_name.clone())?;
1360
1361    let (upstream_schema, upstream_source_name) =
1362        Binder::resolve_schema_qualified_name(&db_name, &cdc_table_info.source_name)?;
1363    let upstream_source = {
1364        let catalog_reader = session.env().catalog_reader().read_guard();
1365        let schema_path = SchemaPath::new(upstream_schema.as_deref(), &search_path, &user_name);
1366        let (source, _) = catalog_reader.get_source_by_name(
1367            &db_name,
1368            schema_path,
1369            upstream_source_name.as_str(),
1370        )?;
1371        source.clone()
1372    };
1373    check_cdc_source_select_privilege(&session, &upstream_source)?;
1374    if !upstream_source.info.is_shared()
1375        || !upstream_source.with_properties.is_shareable_cdc_connector()
1376        || upstream_source.info.external_table.is_some()
1377    {
1378        return Err(ErrorCode::InvalidInputSyntax(format!(
1379            "source `{}` is not a shared CDC source",
1380            cdc_table_info.source_name
1381        ))
1382        .into());
1383    }
1384
1385    let (cdc_with_options, external_table_name) = derive_with_options_for_cdc_table(
1386        &upstream_source.with_properties,
1387        cdc_table_info.external_table_name.clone(),
1388    )?;
1389    let (mut columns, pk_names, pk_comparisons) = match stmt.wildcard_idx {
1390        Some(_) => bind_cdc_table_schema_externally(cdc_with_options.clone()).await?,
1391        None => {
1392            let (columns, pk_names) =
1393                bind_cdc_table_schema(&stmt.columns, &stmt.constraints, false)?;
1394            let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1395                cdc_with_options.clone(),
1396                &pk_names,
1397            ))
1398            .await?;
1399            (columns, pk_names, pk_comparisons)
1400        }
1401    };
1402    if pk_names.is_empty() {
1403        return Err(ErrorCode::NotSupported(
1404            "CDC table source without a primary key is not supported".to_owned(),
1405            "Define a primary key on the upstream table or in the source schema".to_owned(),
1406        )
1407        .into());
1408    }
1409    reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1410
1411    handle_addition_columns(
1412        None,
1413        &cdc_with_options,
1414        stmt.include_column_options.clone(),
1415        &mut columns,
1416        true,
1417    )?;
1418    let mut col_id_gen = ColumnIdGenerator::new_initial();
1419    for column in &mut columns {
1420        col_id_gen.generate(column)?;
1421    }
1422    debug_assert_column_ids_distinct(&columns);
1423    let (columns, pk_col_ids, row_id_index) =
1424        bind_pk_and_row_id_on_relation(columns, pk_names, false)?;
1425    debug_assert!(row_id_index.is_none());
1426
1427    let (connect_properties, secret_refs) = cdc_with_options.into_parts();
1428    let id_to_index = columns
1429        .iter()
1430        .enumerate()
1431        .map(|(index, column)| (column.column_id(), index))
1432        .collect::<HashMap<_, _>>();
1433    let stream_key = pk_col_ids.iter().map(|id| id_to_index[id]).collect_vec();
1434    let pk = stream_key
1435        .iter()
1436        .map(|index| {
1437            risingwave_common::util::sort_util::ColumnOrder::new(
1438                *index,
1439                risingwave_common::util::sort_util::OrderType::ascending(),
1440            )
1441        })
1442        .collect();
1443    let cdc_table_desc = CdcTableDesc {
1444        table_id: TableId::placeholder(),
1445        source_id: upstream_source.id,
1446        external_table_name,
1447        pk,
1448        pk_comparisons,
1449        columns: columns
1450            .iter()
1451            .map(|column| column.column_desc.clone())
1452            .collect(),
1453        stream_key,
1454        // Meta validates this descriptor against the upstream table with these properties and
1455        // then clears them before persisting the catalog: consumers resolve them from
1456        // `source_id` when they are planned, so a persisted copy would only go stale after
1457        // `ALTER SOURCE`.
1458        connect_properties,
1459        secret_refs,
1460    };
1461
1462    let mut source_info = upstream_source.info.clone();
1463    source_info.cdc_source_job = false;
1464    source_info.is_distributed = false;
1465    source_info.external_table = Some(cdc_table_desc.to_protobuf());
1466
1467    // A catalog-only table source keeps just enough connector identity for catalog display and
1468    // source-kind checks. Connection details belong exclusively to the upstream shared source.
1469    let catalog_with_properties = WithOptionsSecResolved::new(
1470        BTreeMap::from([
1471            (
1472                UPSTREAM_SOURCE_KEY.to_owned(),
1473                upstream_source
1474                    .with_properties
1475                    .get_connector()
1476                    .expect("validated as a CDC source"),
1477            ),
1478            (CDC_BACKFILL_ENABLE_KEY.to_owned(), "false".to_owned()),
1479        ]),
1480        BTreeMap::new(),
1481    );
1482
1483    let source_catalog = SourceCatalog {
1484        id: SourceId::placeholder(),
1485        name: source_name,
1486        schema_id,
1487        database_id,
1488        columns,
1489        pk_col_ids,
1490        append_only: false,
1491        owner: session.user_id(),
1492        info: source_info,
1493        row_id_index: None,
1494        with_properties: catalog_with_properties,
1495        watermark_descs: vec![],
1496        associated_table_id: None,
1497        definition: handler_args.normalized_sql,
1498        connection_id: None,
1499        created_at_epoch: None,
1500        initialized_at_epoch: None,
1501        version: INITIAL_SOURCE_VERSION_ID,
1502        created_at_cluster_version: None,
1503        initialized_at_cluster_version: None,
1504        rate_limit: None,
1505        refresh_mode: upstream_source.refresh_mode,
1506    };
1507
1508    session
1509        .catalog_writer()?
1510        .create_source(source_catalog.to_prost(), None, stmt.if_not_exists)
1511        .await?;
1512    Ok(PgResponse::empty_result(StatementType::CREATE_SOURCE))
1513}
1514
1515pub(super) fn generate_stream_graph_for_source(
1516    handler_args: HandlerArgs,
1517    source_catalog: SourceCatalog,
1518) -> Result<PbStreamFragmentGraph> {
1519    let context = OptimizerContext::from_handler_args(handler_args);
1520    let source_node = LogicalSource::with_catalog(
1521        Rc::new(source_catalog),
1522        SourceNodeKind::CreateSharedSource,
1523        context.into(),
1524        None,
1525    )?;
1526
1527    let stream_plan = source_node.to_stream(&mut ToStreamContext::new_with_backfill_type(
1528        false,
1529        // Shared source planning does not create stream table scans, so this required context
1530        // value is only a placeholder and is not used for backfill selection.
1531        BackfillType::ArrangementBackfill,
1532    ))?;
1533    let graph = build_graph(stream_plan, Some(GraphJobType::Source))?;
1534    Ok(graph)
1535}
1536
1537#[cfg(test)]
1538pub mod tests {
1539    use std::collections::HashMap;
1540    use std::sync::Arc;
1541
1542    use risingwave_common::catalog::{
1543        DEFAULT_DATABASE_NAME, DEFAULT_SCHEMA_NAME, ROW_ID_COLUMN_NAME,
1544    };
1545    use risingwave_common::config::FrontendConfig;
1546    use risingwave_common::types::{DataType, StructType};
1547    use risingwave_pb::plan_common::EncodeType;
1548
1549    use crate::catalog::root_catalog::SchemaPath;
1550    use crate::catalog::source_catalog::SourceCatalog;
1551    use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
1552
1553    const GET_COLUMN_FROM_CATALOG: fn(&Arc<SourceCatalog>) -> HashMap<&str, DataType> =
1554        |catalog: &Arc<SourceCatalog>| -> HashMap<&str, DataType> {
1555            catalog
1556                .columns
1557                .iter()
1558                .map(|col| (col.name(), col.data_type().clone()))
1559                .collect::<HashMap<&str, DataType>>()
1560        };
1561
1562    #[tokio::test]
1563    async fn test_create_source_handler() {
1564        let proto_file = create_proto_file(PROTO_FILE_DATA);
1565        let sql = format!(
1566            r#"CREATE SOURCE t
1567    WITH (connector = 'kinesis')
1568    FORMAT PLAIN ENCODE PROTOBUF (message = '.test.TestRecord', schema.location = 'file://{}')"#,
1569            proto_file.path().to_str().unwrap()
1570        );
1571        let frontend = LocalFrontend::new(Default::default()).await;
1572        frontend.run_sql(sql).await.unwrap();
1573
1574        let session = frontend.session_ref();
1575        let catalog_reader = session.env().catalog_reader().read_guard();
1576        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1577
1578        // Check source exists.
1579        let (source, _) = catalog_reader
1580            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
1581            .unwrap();
1582        assert_eq!(source.name, "t");
1583
1584        let columns = GET_COLUMN_FROM_CATALOG(source);
1585
1586        let city_type = StructType::new(vec![
1587            ("address", DataType::Varchar),
1588            ("zipcode", DataType::Varchar),
1589        ])
1590        // .with_ids([5, 6].map(ColumnId::new))
1591        .into();
1592        let expected_columns = maplit::hashmap! {
1593            ROW_ID_COLUMN_NAME => DataType::Serial,
1594            "id" => DataType::Int32,
1595            "zipcode" => DataType::Int64,
1596            "rate" => DataType::Float32,
1597            "country" => StructType::new(
1598                vec![("address", DataType::Varchar),("city", city_type),("zipcode", DataType::Varchar)],
1599            )
1600            // .with_ids([3, 4, 7].map(ColumnId::new))
1601            .into(),
1602        };
1603        assert_eq!(columns, expected_columns, "{columns:#?}");
1604    }
1605
1606    #[tokio::test]
1607    async fn test_create_mqtt_source_with_protobuf() {
1608        let proto_file = create_proto_file(PROTO_FILE_DATA);
1609        let sql = format!(
1610            r#"CREATE SOURCE t_mqtt
1611    WITH (
1612        connector = 'mqtt',
1613        url = 'mqtt://localhost:1883',
1614        topic = 'test_topic'
1615    )
1616    FORMAT PLAIN ENCODE PROTOBUF (
1617        message = '.test.TestRecord',
1618        schema.location = 'file://{}'
1619    )"#,
1620            proto_file.path().to_str().unwrap()
1621        );
1622        let frontend = LocalFrontend::new(Default::default()).await;
1623        frontend.run_sql(sql).await.unwrap();
1624
1625        let session = frontend.session_ref();
1626        let catalog_reader = session.env().catalog_reader().read_guard();
1627        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1628
1629        let (source, _) = catalog_reader
1630            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t_mqtt")
1631            .unwrap();
1632
1633        assert_eq!(source.name, "t_mqtt");
1634        assert_eq!(source.info.row_encode, EncodeType::Protobuf as i32);
1635    }
1636
1637    #[tokio::test]
1638    async fn test_create_posix_fs_source_requires_frontend_config() {
1639        let frontend = LocalFrontend::with_frontend_config(
1640            Default::default(),
1641            FrontendConfig {
1642                unsafe_enable_local_fs_connector: false,
1643                ..Default::default()
1644            },
1645        )
1646        .await;
1647        let err = frontend
1648            .run_sql(
1649                r#"CREATE SOURCE local_files (
1650                    line VARCHAR
1651                ) WITH (
1652                    connector = 'posix_fs',
1653                    posix_fs.root = '/tmp',
1654                    match_pattern = '*.csv'
1655                ) FORMAT PLAIN ENCODE CSV (without_header = 'true')"#
1656                    .to_owned(),
1657            )
1658            .await
1659            .unwrap_err();
1660
1661        assert!(
1662            err.to_string()
1663                .contains("frontend.unsafe_enable_local_fs_connector = true"),
1664            "{err:?}"
1665        );
1666    }
1667
1668    #[tokio::test]
1669    async fn test_duplicate_props_options() {
1670        let proto_file = create_proto_file(PROTO_FILE_DATA);
1671        let sql = format!(
1672            r#"CREATE SOURCE t
1673    WITH (
1674        connector = 'kinesis',
1675        aws.region='user_test_topic',
1676        endpoint='172.10.1.1:9090,172.10.1.2:9090',
1677        aws.credentials.access_key_id = 'your_access_key_1',
1678        aws.credentials.secret_access_key = 'your_secret_key_1'
1679    )
1680    FORMAT PLAIN ENCODE PROTOBUF (
1681        message = '.test.TestRecord',
1682        aws.credentials.access_key_id = 'your_access_key_2',
1683        aws.credentials.secret_access_key = 'your_secret_key_2',
1684        schema.location = 'file://{}',
1685    )"#,
1686            proto_file.path().to_str().unwrap()
1687        );
1688        let frontend = LocalFrontend::new(Default::default()).await;
1689        frontend.run_sql(sql).await.unwrap();
1690
1691        let session = frontend.session_ref();
1692        let catalog_reader = session.env().catalog_reader().read_guard();
1693        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1694
1695        // Check source exists.
1696        let (source, _) = catalog_reader
1697            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
1698            .unwrap();
1699        assert_eq!(source.name, "t");
1700
1701        // AwsAuth params exist in options.
1702        assert_eq!(
1703            source
1704                .info
1705                .format_encode_options
1706                .get("aws.credentials.access_key_id")
1707                .unwrap(),
1708            "your_access_key_2"
1709        );
1710        assert_eq!(
1711            source
1712                .info
1713                .format_encode_options
1714                .get("aws.credentials.secret_access_key")
1715                .unwrap(),
1716            "your_secret_key_2"
1717        );
1718
1719        // AwsAuth params exist in props.
1720        assert_eq!(
1721            source
1722                .with_properties
1723                .get("aws.credentials.access_key_id")
1724                .unwrap(),
1725            "your_access_key_1"
1726        );
1727        assert_eq!(
1728            source
1729                .with_properties
1730                .get("aws.credentials.secret_access_key")
1731                .unwrap(),
1732            "your_secret_key_1"
1733        );
1734
1735        // Options are not merged into props.
1736        assert!(!source.with_properties.contains_key("schema.location"));
1737    }
1738
1739    #[tokio::test]
1740    async fn test_multi_table_cdc_create_source_handler() {
1741        let sql =
1742            "CREATE SOURCE t2 WITH (connector = 'mysql-cdc') FORMAT PLAIN ENCODE JSON".to_owned();
1743        let frontend = LocalFrontend::new(Default::default()).await;
1744        let session = frontend.session_ref();
1745
1746        frontend
1747            .run_sql_with_session(session.clone(), sql)
1748            .await
1749            .unwrap();
1750        let catalog_reader = session.env().catalog_reader().read_guard();
1751        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
1752
1753        // Check source exists.
1754        let (source, _) = catalog_reader
1755            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t2")
1756            .unwrap();
1757        assert_eq!(source.name, "t2");
1758
1759        let columns = source
1760            .columns
1761            .iter()
1762            .map(|col| (col.name(), col.data_type().clone()))
1763            .collect::<Vec<(&str, DataType)>>();
1764
1765        expect_test::expect![[r#"
1766            [
1767                (
1768                    "payload",
1769                    Jsonb,
1770                ),
1771                (
1772                    "_rw_offset",
1773                    Varchar,
1774                ),
1775                (
1776                    "_rw_table_name",
1777                    Varchar,
1778                ),
1779                (
1780                    "_row_id",
1781                    Serial,
1782                ),
1783            ]
1784        "#]]
1785        .assert_debug_eq(&columns);
1786    }
1787
1788    #[tokio::test]
1789    async fn test_reject_zero_source_rate_limit_when_cdc_must_wait_for_offset() {
1790        let frontend = LocalFrontend::new(Default::default()).await;
1791        let session = frontend.session_ref();
1792
1793        for (source_name, connector) in [
1794            ("mysql_explicit_zero", "mysql-cdc"),
1795            ("sqlserver_explicit_zero", "sqlserver-cdc"),
1796        ] {
1797            let err = frontend
1798                .run_sql_with_session(
1799                    session.clone(),
1800                    format!(
1801                        "CREATE SOURCE {source_name} WITH (connector = '{connector}', source_rate_limit = '0') FORMAT PLAIN ENCODE JSON"
1802                    ),
1803                )
1804                .await
1805                .unwrap_err();
1806            let message = err.to_string();
1807            assert!(
1808                message.contains("source creation must wait for the initial CDC offset"),
1809                "{message}"
1810            );
1811        }
1812
1813        frontend
1814            .run_sql_with_session(session.clone(), "SET source_rate_limit TO 0;")
1815            .await
1816            .unwrap();
1817
1818        for (source_name, connector) in [
1819            ("mysql_session_zero", "mysql-cdc"),
1820            ("sqlserver_session_zero", "sqlserver-cdc"),
1821        ] {
1822            let err = frontend
1823                .run_sql_with_session(
1824                    session.clone(),
1825                    format!(
1826                        "CREATE SOURCE {source_name} WITH (connector = '{connector}') FORMAT PLAIN ENCODE JSON"
1827                    ),
1828                )
1829                .await
1830                .unwrap_err();
1831            assert!(
1832                err.to_string()
1833                    .contains("source creation must wait for the initial CDC offset"),
1834                "{err}"
1835            );
1836        }
1837
1838        frontend
1839            .run_sql_with_session(
1840                session.clone(),
1841                "CREATE SOURCE mysql_positive_override WITH (connector = 'mysql-cdc', source_rate_limit = '1') FORMAT PLAIN ENCODE JSON",
1842            )
1843            .await
1844            .unwrap();
1845
1846        frontend
1847            .run_sql_with_session(
1848                session,
1849                "CREATE SOURCE postgres_zero WITH (connector = 'postgres-cdc') FORMAT PLAIN ENCODE JSON",
1850            )
1851            .await
1852            .unwrap();
1853    }
1854
1855    #[tokio::test]
1856    async fn test_source_addition_columns() {
1857        // test derive include column for format plain
1858        let sql =
1859            "CREATE SOURCE s (v1 int) include key as _rw_kafka_key with (connector = 'kafka') format plain encode json".to_owned();
1860        let frontend = LocalFrontend::new(Default::default()).await;
1861        frontend.run_sql(sql).await.unwrap();
1862        let session = frontend.session_ref();
1863        let catalog_reader = session.env().catalog_reader().read_guard();
1864        let (source, _) = catalog_reader
1865            .get_source_by_name(
1866                DEFAULT_DATABASE_NAME,
1867                SchemaPath::Name(DEFAULT_SCHEMA_NAME),
1868                "s",
1869            )
1870            .unwrap();
1871        assert_eq!(source.name, "s");
1872
1873        let columns = source
1874            .columns
1875            .iter()
1876            .map(|col| (col.name(), col.data_type().clone()))
1877            .collect::<Vec<(&str, DataType)>>();
1878
1879        expect_test::expect![[r#"
1880            [
1881                (
1882                    "v1",
1883                    Int32,
1884                ),
1885                (
1886                    "_rw_kafka_key",
1887                    Bytea,
1888                ),
1889                (
1890                    "_rw_kafka_timestamp",
1891                    Timestamptz,
1892                ),
1893                (
1894                    "_rw_kafka_partition",
1895                    Varchar,
1896                ),
1897                (
1898                    "_rw_kafka_offset",
1899                    Varchar,
1900                ),
1901                (
1902                    "_row_id",
1903                    Serial,
1904                ),
1905            ]
1906        "#]]
1907        .assert_debug_eq(&columns);
1908        drop(catalog_reader);
1909
1910        let sql =
1911            "CREATE SOURCE s_pulsar (v1 int) include header 'tenant' as pulsar_header with (connector = 'pulsar') format plain encode json".to_owned();
1912        frontend.run_sql(sql).await.unwrap();
1913        let catalog_reader = session.env().catalog_reader().read_guard();
1914        let (source, _) = catalog_reader
1915            .get_source_by_name(
1916                DEFAULT_DATABASE_NAME,
1917                SchemaPath::Name(DEFAULT_SCHEMA_NAME),
1918                "s_pulsar",
1919            )
1920            .unwrap();
1921        assert_eq!(source.name, "s_pulsar");
1922
1923        let columns = source
1924            .columns
1925            .iter()
1926            .map(|col| (col.name(), col.data_type().clone()))
1927            .collect::<Vec<(&str, DataType)>>();
1928
1929        expect_test::expect![[r#"
1930            [
1931                (
1932                    "v1",
1933                    Int32,
1934                ),
1935                (
1936                    "pulsar_header",
1937                    Bytea,
1938                ),
1939                (
1940                    "_row_id",
1941                    Serial,
1942                ),
1943            ]
1944        "#]]
1945        .assert_debug_eq(&columns);
1946        drop(catalog_reader);
1947
1948        let sql =
1949            "CREATE SOURCE s3 (v1 int) include timestamp 'header1' as header_col with (connector = 'kafka') format plain encode json".to_owned();
1950        match frontend.run_sql(sql).await {
1951            Err(e) => {
1952                assert_eq!(
1953                    e.to_string(),
1954                    "Protocol error: Only header column can have inner field, but got \"timestamp\""
1955                )
1956            }
1957            _ => unreachable!(),
1958        }
1959    }
1960}