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