Skip to main content

risingwave_frontend/handler/
create_source.rs

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