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