Skip to main content

risingwave_frontend/handler/
create_table.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::Arc;
18
19use anyhow::{Context, anyhow};
20use clap::ValueEnum;
21use either::Either;
22use fancy_regex::Regex;
23use fixedbitset::FixedBitSet;
24use itertools::Itertools;
25use percent_encoding::percent_decode_str;
26use pgwire::pg_response::{PgResponse, StatementType};
27use prost::Message as _;
28use risingwave_common::catalog::{
29    CdcTableDesc, ColumnCatalog, ColumnDesc, ConflictBehavior, DEFAULT_SCHEMA_NAME, Engine,
30    ICEBERG_SINK_PREFIX, ICEBERG_SOURCE_PREFIX, RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME,
31    TableId,
32};
33use risingwave_common::config::MetaBackend;
34use risingwave_common::global_jvm::Jvm;
35use risingwave_common::session_config::sink_decouple::SinkDecouple;
36use risingwave_common::util::sort_util::{ColumnOrder, OrderType};
37use risingwave_common::util::value_encoding::DatumToProtoExt;
38use risingwave_common::{bail, bail_not_implemented};
39use risingwave_connector::sink::decouple_checkpoint_log_sink::COMMIT_CHECKPOINT_INTERVAL;
40use risingwave_connector::source::cdc::external::{
41    DATABASE_NAME_KEY, ExternalCdcTableType, ExternalTableConfig, ExternalTableImpl,
42    SCHEMA_NAME_KEY, SchemaTableName, TABLE_NAME_KEY,
43};
44use risingwave_connector::source::cdc::{
45    build_cdc_table_id, normalize_simple_postgres_quoted_table_name,
46};
47use risingwave_connector::{
48    AUTO_SCHEMA_CHANGE_KEY, WithOptionsSecResolved, WithPropertiesExt, source,
49};
50use risingwave_pb::catalog::connection::Info as ConnectionInfo;
51use risingwave_pb::catalog::connection_params::ConnectionType;
52use risingwave_pb::catalog::{PbSource, PbWebhookSourceInfo, WatermarkDesc};
53use risingwave_pb::ddl_service::{PbTableJobType, TableJobType};
54use risingwave_pb::plan_common::column_desc::GeneratedOrDefaultColumn;
55use risingwave_pb::plan_common::{
56    AdditionalColumn, ColumnDescVersion, DefaultColumnDesc, GeneratedColumnDesc,
57};
58use risingwave_pb::secret::PbSecretRef;
59use risingwave_pb::secret::secret_ref::PbRefAsType;
60use risingwave_pb::stream_plan::StreamFragmentGraph;
61use risingwave_sqlparser::ast::{
62    CdcTableInfo, ColumnDef, ColumnOption, CompatibleFormatEncode, ConnectionRefValue, CreateSink,
63    CreateSinkStatement, CreateSourceStatement, DataType as AstDataType, ExplainOptions, Format,
64    FormatEncodeOptions, Ident, ObjectName, OnConflict, SecretRefAsType, SourceWatermark,
65    Statement, TableConstraint, WebhookSourceInfo, WithProperties,
66};
67use risingwave_sqlparser::parser::IncludeOption;
68use thiserror_ext::AsReport;
69
70use super::RwPgResponse;
71use super::create_source::{CreateSourceType, SqlColumnStrategy, bind_columns_from_source};
72use crate::binder::{Clause, SecureCompareContext, WEBHOOK_PAYLOAD_FIELD_NAME, bind_data_type};
73use crate::catalog::root_catalog::SchemaPath;
74use crate::catalog::source_catalog::SourceCatalog;
75use crate::catalog::table_catalog::TableVersion;
76use crate::catalog::{ColumnId, DatabaseId, SchemaId, SourceId, check_column_name_not_reserved};
77use crate::error::{ErrorCode, Result, RwError, bail_bind_error};
78use crate::expr::{Expr, ExprImpl, ExprRewriter};
79use crate::handler::HandlerArgs;
80use crate::handler::create_source::{
81    UPSTREAM_SOURCE_KEY, bind_connector_props, bind_create_source_or_table_with_connector,
82    bind_source_watermark, handle_addition_columns,
83};
84use crate::handler::util::{
85    LongRunningNotificationAction, SourceSchemaCompatExt, execute_with_long_running_notification,
86};
87use crate::optimizer::plan_node::generic::{SourceNodeKind, build_cdc_scan_options_with_options};
88use crate::optimizer::plan_node::{
89    LogicalCdcScan, LogicalPlanRef, LogicalSource, StreamPlanRef as PlanRef,
90    ensure_sync_log_store_fragment_root,
91};
92use crate::optimizer::property::{Order, RequiredDist};
93use crate::optimizer::{OptimizerContext, OptimizerContextRef, PlanRoot};
94use crate::session::SessionImpl;
95use crate::session::current::notice_to_user;
96use crate::stream_fragmenter::{GraphJobType, build_graph};
97use crate::utils::OverwriteOptions;
98use crate::{Binder, Explain, TableCatalog, WithOptions};
99
100mod col_id_gen;
101pub use col_id_gen::*;
102use risingwave_connector::sink::SinkParam;
103use risingwave_connector::sink::iceberg::{
104    COMPACTION_DELETE_FILES_COUNT_THRESHOLD, COMPACTION_INTERVAL_SEC, COMPACTION_MAX_SNAPSHOTS_NUM,
105    COMPACTION_SMALL_FILES_THRESHOLD_MB, COMPACTION_TARGET_FILE_SIZE_MB,
106    COMPACTION_TRIGGER_SNAPSHOT_COUNT, COMPACTION_TYPE, COMPACTION_WRITE_PARQUET_COMPRESSION,
107    COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES, COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS,
108    CompactionType, ENABLE_COMPACTION, ENABLE_PK_INDEX, ENABLE_SNAPSHOT_EXPIRATION, FORMAT_VERSION,
109    ICEBERG_WRITE_MODE_COPY_ON_WRITE, ICEBERG_WRITE_MODE_MERGE_ON_READ, IcebergSink,
110    IcebergWriteMode, ORDER_KEY, SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES,
111    SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA, SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS,
112    SNAPSHOT_EXPIRATION_RETAIN_LAST, WRITE_MODE, parse_partition_by_exprs,
113    validate_order_key_columns,
114};
115use risingwave_pb::ddl_service::create_iceberg_table_request::{PbSinkJobInfo, PbTableJobInfo};
116
117use crate::handler::create_sink::{SinkPlanContext, gen_sink_plan};
118
119fn ensure_column_options_supported(c: &ColumnDef) -> Result<()> {
120    for option_def in &c.options {
121        match option_def.option {
122            ColumnOption::GeneratedColumns(_) => {}
123            ColumnOption::DefaultValue(_) => {}
124            ColumnOption::DefaultValueInternal { .. } => {}
125            ColumnOption::Unique { is_primary: true } => {}
126            ColumnOption::Null => {}
127            ColumnOption::NotNull => {}
128            _ => bail_not_implemented!("column constraints \"{}\"", option_def),
129        }
130    }
131    Ok(())
132}
133
134/// Binds the column schemas declared in CREATE statement into `ColumnDesc`.
135/// If a column is marked as `primary key`, its `ColumnId` is also returned.
136/// This primary key is not combined with table constraints yet.
137pub fn bind_sql_columns(
138    column_defs: &[ColumnDef],
139    is_for_drop_table_connector: bool,
140) -> Result<Vec<ColumnCatalog>> {
141    let mut columns = Vec::with_capacity(column_defs.len());
142
143    for column in column_defs {
144        ensure_column_options_supported(column)?;
145        // Destruct to make sure all fields are properly handled rather than ignored.
146        // Do NOT use `..` to ignore fields you do not want to deal with.
147        // Reject them with a clear NotImplemented error.
148        let ColumnDef {
149            name,
150            data_type,
151            collation,
152            options,
153            ..
154        } = column;
155
156        let data_type = data_type
157            .clone()
158            .ok_or_else(|| ErrorCode::InvalidInputSyntax("data type is not specified".into()))?;
159        if let Some(collation) = collation {
160            // PostgreSQL will limit the datatypes that collate can work on.
161            // https://www.postgresql.org/docs/16/collation.html#COLLATION-CONCEPTS
162            //   > The built-in collatable data types are `text`, `varchar`, and `char`.
163            //
164            // But we don't support real collation, we simply ignore it here.
165            if !["C", "POSIX"].contains(&collation.real_value().as_str()) {
166                bail_not_implemented!(
167                    "Collate collation other than `C` or `POSIX` is not implemented"
168                );
169            }
170
171            match data_type {
172                AstDataType::Text | AstDataType::Varchar | AstDataType::Char(_) => {}
173                _ => {
174                    return Err(ErrorCode::NotSupported(
175                        format!("{} is not a collatable data type", data_type),
176                        "The only built-in collatable data types are `varchar`, please check your type".into(),
177                    ).into());
178                }
179            }
180        }
181
182        if !is_for_drop_table_connector {
183            // additional column name may have prefix _rw
184            // When converting dropping the connector from table, the additional columns are converted to normal columns and keep the original name.
185            // Under this case, we loosen the check for _rw prefix.
186            check_column_name_not_reserved(&name.real_value())?;
187        }
188
189        let nullable: bool = !options
190            .iter()
191            .any(|def| matches!(def.option, ColumnOption::NotNull));
192
193        columns.push(ColumnCatalog {
194            column_desc: ColumnDesc {
195                data_type: bind_data_type(&data_type)?,
196                column_id: ColumnId::placeholder(),
197                name: name.real_value(),
198                generated_or_default_column: None,
199                description: None,
200                additional_column: AdditionalColumn { column_type: None },
201                version: ColumnDescVersion::LATEST,
202                system_column: None,
203                nullable,
204            },
205            is_hidden: false,
206        });
207    }
208
209    Ok(columns)
210}
211
212fn check_generated_column_constraints(
213    column_name: &String,
214    column_id: ColumnId,
215    expr: &ExprImpl,
216    column_catalogs: &[ColumnCatalog],
217    generated_column_names: &[String],
218    pk_column_ids: &[ColumnId],
219) -> Result<()> {
220    let input_refs = expr.collect_input_refs(column_catalogs.len());
221    for idx in input_refs.ones() {
222        let referred_generated_column = &column_catalogs[idx].column_desc.name;
223        if generated_column_names
224            .iter()
225            .any(|c| c == referred_generated_column)
226        {
227            return Err(ErrorCode::BindError(format!(
228                "Generated can not reference another generated column. \
229                But here generated column \"{}\" referenced another generated column \"{}\"",
230                column_name, referred_generated_column
231            ))
232            .into());
233        }
234    }
235
236    if pk_column_ids.contains(&column_id) && expr.is_impure() {
237        return Err(ErrorCode::BindError(format!(
238            "Generated columns with impure expressions should not be part of the primary key. \
239            Here column \"{}\" is defined as part of the primary key.",
240            column_name
241        ))
242        .into());
243    }
244
245    Ok(())
246}
247
248/// Binds constraints that can be only specified in column definitions,
249/// currently generated columns and default columns.
250pub fn bind_sql_column_constraints(
251    session: &SessionImpl,
252    table_name: String,
253    column_catalogs: &mut [ColumnCatalog],
254    columns: &[ColumnDef],
255    pk_column_ids: &[ColumnId],
256) -> Result<()> {
257    let generated_column_names = {
258        let mut names = vec![];
259        for column in columns {
260            for option_def in &column.options {
261                if let ColumnOption::GeneratedColumns(_) = option_def.option {
262                    names.push(column.name.real_value());
263                    break;
264                }
265            }
266        }
267        names
268    };
269
270    let mut binder = Binder::new_for_ddl(session);
271    binder.bind_columns_to_context(table_name, column_catalogs)?;
272
273    for column in columns {
274        let Some(idx) = column_catalogs
275            .iter()
276            .position(|c| c.name() == column.name.real_value())
277        else {
278            // It's possible that we don't follow the user defined columns in SQL but take the
279            // ones resolved from the source, thus missing some columns. Simply ignore them.
280            continue;
281        };
282
283        for option_def in &column.options {
284            match &option_def.option {
285                ColumnOption::GeneratedColumns(expr) => {
286                    binder.set_clause(Some(Clause::GeneratedColumn));
287
288                    let expr_impl = binder.bind_expr(expr).with_context(|| {
289                        format!(
290                            "fail to bind expression in generated column \"{}\"",
291                            column.name.real_value()
292                        )
293                    })?;
294
295                    check_generated_column_constraints(
296                        &column.name.real_value(),
297                        column_catalogs[idx].column_id(),
298                        &expr_impl,
299                        column_catalogs,
300                        &generated_column_names,
301                        pk_column_ids,
302                    )?;
303
304                    column_catalogs[idx].column_desc.generated_or_default_column = Some(
305                        GeneratedOrDefaultColumn::GeneratedColumn(GeneratedColumnDesc {
306                            expr: Some(expr_impl.to_expr_proto()),
307                        }),
308                    );
309                    binder.set_clause(None);
310                }
311                ColumnOption::DefaultValue(expr) => {
312                    let expr_impl = binder
313                        .bind_expr(expr)?
314                        .cast_assign(column_catalogs[idx].data_type())?;
315
316                    // Rewrite expressions to evaluate a snapshot value, used for missing values in the case of
317                    // schema change.
318                    //
319                    // TODO: Currently we don't support impure expressions other than `now()` (like `random()`),
320                    // so the rewritten expression should almost always be pure and we directly call `fold_const`
321                    // here. Actually we do not require purity of the expression here since we're only to get a
322                    // snapshot value.
323                    let rewritten_expr_impl = session
324                        .pinned_snapshot()
325                        .inline_now_proc_time()
326                        .rewrite_expr(expr_impl.clone());
327
328                    if let Some(snapshot_value) = rewritten_expr_impl.try_fold_const() {
329                        let snapshot_value = snapshot_value?;
330
331                        column_catalogs[idx].column_desc.generated_or_default_column =
332                            Some(GeneratedOrDefaultColumn::DefaultColumn(DefaultColumnDesc {
333                                snapshot_value: Some(snapshot_value.to_protobuf()),
334                                expr: Some(expr_impl.to_expr_proto()),
335                                // persist the original expression
336                            }));
337                    } else {
338                        return Err(ErrorCode::BindError(format!(
339                            "Default expression used in column `{}` cannot be evaluated. \
340                            Use generated columns instead if you mean to reference other columns.",
341                            column.name
342                        ))
343                        .into());
344                    }
345                }
346                ColumnOption::DefaultValueInternal { persisted, expr: _ } => {
347                    // When a `DEFAULT INTERNAL` is used internally for schema change, the persisted value
348                    // should already be set during purifcation. So if we encounter an empty value here, it
349                    // means the user has specified it explicitly in the SQL statement, typically by
350                    // directly copying the result of `SHOW CREATE TABLE` and executing it.
351                    if persisted.is_empty() {
352                        bail_bind_error!(
353                            "DEFAULT INTERNAL is only used for internal purposes, \
354                             please specify a concrete default value"
355                        );
356                    }
357
358                    let desc = DefaultColumnDesc::decode(&**persisted)
359                        .expect("failed to decode persisted `DefaultColumnDesc`");
360
361                    column_catalogs[idx].column_desc.generated_or_default_column =
362                        Some(GeneratedOrDefaultColumn::DefaultColumn(desc));
363                }
364                _ => {}
365            }
366        }
367    }
368    Ok(())
369}
370
371/// Currently we only support Primary key table constraint, so just return pk names if it exists
372pub fn bind_table_constraints(table_constraints: &[TableConstraint]) -> Result<Vec<String>> {
373    let mut pk_column_names = vec![];
374
375    for constraint in table_constraints {
376        match constraint {
377            TableConstraint::Unique {
378                name: _,
379                columns,
380                is_primary: true,
381            } => {
382                if !pk_column_names.is_empty() {
383                    return Err(multiple_pk_definition_err());
384                }
385                pk_column_names = columns.iter().map(|c| c.real_value()).collect_vec();
386            }
387            _ => bail_not_implemented!("table constraint \"{}\"", constraint),
388        }
389    }
390    Ok(pk_column_names)
391}
392
393pub fn bind_sql_pk_names(
394    columns_defs: &[ColumnDef],
395    pk_names_from_table_constraints: Vec<String>,
396) -> Result<Vec<String>> {
397    let mut pk_column_names = pk_names_from_table_constraints;
398
399    for column in columns_defs {
400        for option_def in &column.options {
401            if let ColumnOption::Unique { is_primary: true } = option_def.option {
402                if !pk_column_names.is_empty() {
403                    return Err(multiple_pk_definition_err());
404                }
405                pk_column_names.push(column.name.real_value());
406            };
407        }
408    }
409
410    Ok(pk_column_names)
411}
412
413fn multiple_pk_definition_err() -> RwError {
414    ErrorCode::BindError("multiple primary keys are not allowed".into()).into()
415}
416
417/// Binds primary keys defined in SQL.
418///
419/// It returns the columns together with `pk_column_ids`, and an optional row id column index if
420/// added.
421pub fn bind_pk_and_row_id_on_relation(
422    mut columns: Vec<ColumnCatalog>,
423    pk_names: Vec<String>,
424    must_need_pk: bool,
425) -> Result<(Vec<ColumnCatalog>, Vec<ColumnId>, Option<usize>)> {
426    for c in &columns {
427        assert!(c.column_id() != ColumnId::placeholder());
428    }
429
430    // Mapping from column name to column id.
431    let name_to_id = columns
432        .iter()
433        .map(|c| (c.name(), c.column_id()))
434        .collect::<HashMap<_, _>>();
435
436    let mut pk_column_ids: Vec<_> = pk_names
437        .iter()
438        .map(|name| {
439            name_to_id.get(name.as_str()).copied().ok_or_else(|| {
440                ErrorCode::BindError(format!("column \"{name}\" named in key does not exist"))
441            })
442        })
443        .try_collect()?;
444
445    // Add `_row_id` column if `pk_column_ids` is empty and must_need_pk
446    let need_row_id = pk_column_ids.is_empty() && must_need_pk;
447
448    let row_id_index = need_row_id.then(|| {
449        let column = ColumnCatalog::row_id_column();
450        let index = columns.len();
451        pk_column_ids = vec![column.column_id()];
452        columns.push(column);
453        index
454    });
455
456    if let Some(col) = columns.iter().map(|c| c.name()).duplicates().next() {
457        Err(ErrorCode::InvalidInputSyntax(format!(
458            "column \"{col}\" specified more than once"
459        )))?;
460    }
461
462    Ok((columns, pk_column_ids, row_id_index))
463}
464
465/// `gen_create_table_plan_with_source` generates the plan for creating a table with an external
466/// stream source.
467#[allow(clippy::too_many_arguments)]
468pub(crate) async fn gen_create_table_plan_with_source(
469    mut handler_args: HandlerArgs,
470    explain_options: ExplainOptions,
471    table_name: ObjectName,
472    column_defs: Vec<ColumnDef>,
473    wildcard_idx: Option<usize>,
474    constraints: Vec<TableConstraint>,
475    format_encode: FormatEncodeOptions,
476    source_watermarks: Vec<SourceWatermark>,
477    mut col_id_gen: ColumnIdGenerator,
478    include_column_options: IncludeOption,
479    props: CreateTableProps,
480    sql_column_strategy: SqlColumnStrategy,
481) -> Result<(PlanRef, Option<SourceCatalog>, TableCatalog)> {
482    if props.append_only
483        && format_encode.format != Format::Plain
484        && format_encode.format != Format::Native
485    {
486        return Err(ErrorCode::BindError(format!(
487            "Append only table does not support format {}.",
488            format_encode.format
489        ))
490        .into());
491    }
492
493    let session = &handler_args.session;
494    let (with_properties, refresh_mode) =
495        bind_connector_props(&handler_args, &format_encode, false)?;
496    if with_properties.is_shareable_cdc_connector() {
497        generated_columns_check_for_cdc_table(&column_defs)?;
498        not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
499    } else if column_defs.iter().any(|col| {
500        col.options
501            .iter()
502            .any(|def| matches!(def.option, ColumnOption::NotNull))
503    }) {
504        // if non-cdc source
505        notice_to_user(
506            "The table contains columns with NOT NULL constraints. Any rows from upstream violating the constraints will be ignored silently.",
507        );
508    }
509
510    let db_name: &str = &session.database();
511    let (schema_name, _) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
512
513    // TODO: omit this step if `sql_column_strategy` is `Follow`.
514    let (columns_from_resolve_source, source_info) = bind_columns_from_source(
515        session,
516        &format_encode,
517        Either::Left(&with_properties),
518        CreateSourceType::Table,
519    )
520    .await?;
521
522    let overwrite_options = OverwriteOptions::new(&mut handler_args);
523    let rate_limit = overwrite_options.source_rate_limit;
524    let source = bind_create_source_or_table_with_connector(
525        handler_args.clone(),
526        table_name,
527        format_encode,
528        with_properties,
529        &column_defs,
530        constraints,
531        wildcard_idx,
532        source_watermarks,
533        columns_from_resolve_source,
534        source_info,
535        include_column_options,
536        &mut col_id_gen,
537        CreateSourceType::Table,
538        rate_limit,
539        sql_column_strategy,
540        refresh_mode,
541    )
542    .await?;
543
544    let context = OptimizerContext::new(handler_args, explain_options);
545
546    let (plan, table) = gen_table_plan_with_source(
547        context.into(),
548        schema_name,
549        source.clone(),
550        col_id_gen.into_version(),
551        props,
552    )?;
553
554    Ok((plan, Some(source), table))
555}
556
557/// `gen_create_table_plan` generates the plan for creating a table without an external stream
558/// source.
559#[allow(clippy::too_many_arguments)]
560pub(crate) fn gen_create_table_plan(
561    context: OptimizerContext,
562    table_name: ObjectName,
563    column_defs: Vec<ColumnDef>,
564    constraints: Vec<TableConstraint>,
565    mut col_id_gen: ColumnIdGenerator,
566    source_watermarks: Vec<SourceWatermark>,
567    props: CreateTableProps,
568    is_for_replace_plan: bool,
569) -> Result<(PlanRef, TableCatalog)> {
570    let mut columns = bind_sql_columns(&column_defs, is_for_replace_plan)?;
571    for c in &mut columns {
572        col_id_gen.generate(c)?;
573    }
574
575    let (_, secret_refs, connection_refs) = context.with_options().clone().into_parts();
576    if !secret_refs.is_empty() || !connection_refs.is_empty() {
577        return Err(crate::error::ErrorCode::InvalidParameterValue("Secret reference and Connection reference are not allowed in options when creating table without external source".to_owned()).into());
578    }
579
580    gen_create_table_plan_without_source(
581        context,
582        table_name,
583        columns,
584        column_defs,
585        constraints,
586        source_watermarks,
587        col_id_gen.into_version(),
588        props,
589    )
590}
591
592#[allow(clippy::too_many_arguments)]
593pub(crate) fn gen_create_table_plan_without_source(
594    context: OptimizerContext,
595    table_name: ObjectName,
596    columns: Vec<ColumnCatalog>,
597    column_defs: Vec<ColumnDef>,
598    constraints: Vec<TableConstraint>,
599    source_watermarks: Vec<SourceWatermark>,
600    version: TableVersion,
601    props: CreateTableProps,
602) -> Result<(PlanRef, TableCatalog)> {
603    // XXX: Why not bind outside?
604    let pk_names = bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
605    let (mut columns, pk_column_ids, row_id_index) =
606        bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
607
608    let watermark_descs = bind_source_watermark(
609        context.session_ctx(),
610        table_name.real_value(),
611        source_watermarks,
612        &columns,
613    )?;
614
615    bind_sql_column_constraints(
616        context.session_ctx(),
617        table_name.real_value(),
618        &mut columns,
619        &column_defs,
620        &pk_column_ids,
621    )?;
622    let session = context.session_ctx().clone();
623
624    let db_name = &session.database();
625    let (schema_name, table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
626
627    let info = CreateTableInfo {
628        columns,
629        pk_column_ids,
630        row_id_index,
631        watermark_descs,
632        source_catalog: None,
633        version,
634    };
635
636    gen_table_plan_inner(context.into(), schema_name, table_name, info, props)
637}
638
639fn gen_table_plan_with_source(
640    context: OptimizerContextRef,
641    schema_name: Option<String>,
642    source_catalog: SourceCatalog,
643    version: TableVersion,
644    props: CreateTableProps,
645) -> Result<(PlanRef, TableCatalog)> {
646    let table_name = source_catalog.name.clone();
647
648    let info = CreateTableInfo {
649        columns: source_catalog.columns.clone(),
650        pk_column_ids: source_catalog.pk_col_ids.clone(),
651        row_id_index: source_catalog.row_id_index,
652        watermark_descs: source_catalog.watermark_descs.clone(),
653        source_catalog: Some(source_catalog),
654        version,
655    };
656
657    gen_table_plan_inner(context, schema_name, table_name, info, props)
658}
659
660/// On-conflict behavior either from user input or existing table catalog.
661#[derive(Clone, Copy)]
662pub enum EitherOnConflict {
663    Ast(Option<OnConflict>),
664    Resolved(ConflictBehavior),
665}
666
667impl From<Option<OnConflict>> for EitherOnConflict {
668    fn from(v: Option<OnConflict>) -> Self {
669        Self::Ast(v)
670    }
671}
672
673impl From<ConflictBehavior> for EitherOnConflict {
674    fn from(v: ConflictBehavior) -> Self {
675        Self::Resolved(v)
676    }
677}
678
679impl EitherOnConflict {
680    /// Resolves the conflict behavior based on the given information.
681    pub fn to_behavior(self, append_only: bool, row_id_as_pk: bool) -> Result<ConflictBehavior> {
682        let conflict_behavior = match self {
683            EitherOnConflict::Ast(on_conflict) => {
684                if append_only {
685                    if row_id_as_pk {
686                        // Primary key will be generated, no conflict check needed.
687                        ConflictBehavior::NoCheck
688                    } else {
689                        // User defined PK on append-only table, enforce `DO NOTHING`.
690                        if let Some(on_conflict) = on_conflict
691                            && on_conflict != OnConflict::Nothing
692                        {
693                            return Err(ErrorCode::InvalidInputSyntax(
694                                "When PRIMARY KEY constraint applied to an APPEND ONLY table, \
695                                     the ON CONFLICT behavior must be DO NOTHING."
696                                    .to_owned(),
697                            )
698                            .into());
699                        }
700                        ConflictBehavior::IgnoreConflict
701                    }
702                } else {
703                    // Default to `UPDATE FULL` for non-append-only tables.
704                    match on_conflict.unwrap_or(OnConflict::UpdateFull) {
705                        OnConflict::UpdateFull => ConflictBehavior::Overwrite,
706                        OnConflict::Nothing => ConflictBehavior::IgnoreConflict,
707                        OnConflict::UpdateIfNotNull => ConflictBehavior::DoUpdateIfNotNull,
708                    }
709                }
710            }
711            EitherOnConflict::Resolved(b) => b,
712        };
713
714        Ok(conflict_behavior)
715    }
716}
717
718/// Arguments of the functions that generate a table plan, part 1.
719///
720/// Compared to [`CreateTableProps`], this struct contains fields that need some work of binding
721/// or resolving based on the user input.
722pub struct CreateTableInfo {
723    pub columns: Vec<ColumnCatalog>,
724    pub pk_column_ids: Vec<ColumnId>,
725    pub row_id_index: Option<usize>,
726    pub watermark_descs: Vec<WatermarkDesc>,
727    pub source_catalog: Option<SourceCatalog>,
728    pub version: TableVersion,
729}
730
731/// Arguments of the functions that generate a table plan, part 2.
732///
733/// Compared to [`CreateTableInfo`], this struct contains fields that can be (relatively) simply
734/// obtained from the input or the context.
735pub struct CreateTableProps {
736    pub definition: String,
737    pub append_only: bool,
738    pub on_conflict: EitherOnConflict,
739    pub with_version_columns: Vec<String>,
740    pub webhook_info: Option<PbWebhookSourceInfo>,
741    pub engine: Engine,
742}
743
744#[allow(clippy::too_many_arguments)]
745fn gen_table_plan_inner(
746    context: OptimizerContextRef,
747    schema_name: Option<String>,
748    table_name: String,
749    info: CreateTableInfo,
750    props: CreateTableProps,
751) -> Result<(PlanRef, TableCatalog)> {
752    let CreateTableInfo {
753        ref columns,
754        row_id_index,
755        ref watermark_descs,
756        ref source_catalog,
757        ..
758    } = info;
759    let CreateTableProps { append_only, .. } = props;
760
761    let (database_id, schema_id) = context
762        .session_ctx()
763        .get_database_and_schema_id_for_create(schema_name)?;
764
765    let session = context.session_ctx().clone();
766    let retention_seconds = context.with_options().retention_seconds();
767
768    let source_node: LogicalPlanRef = LogicalSource::new(
769        source_catalog.clone().map(Rc::new),
770        columns.clone(),
771        row_id_index,
772        SourceNodeKind::CreateTable,
773        context.clone(),
774        None,
775    )?
776    .into();
777
778    let required_cols = FixedBitSet::with_capacity(columns.len());
779    let plan_root = PlanRoot::new_with_logical_plan(
780        source_node,
781        RequiredDist::Any,
782        Order::any(),
783        required_cols,
784        vec![],
785    );
786
787    let has_non_ttl_watermark = watermark_descs.iter().any(|d| !d.with_ttl);
788
789    if !append_only && has_non_ttl_watermark {
790        return Err(ErrorCode::NotSupported(
791            "Defining watermarks on table requires the table to be append only.".to_owned(),
792            "Use the key words `APPEND ONLY`".to_owned(),
793        )
794        .into());
795    }
796
797    if !append_only && retention_seconds.is_some() {
798        if session
799            .config()
800            .unsafe_enable_storage_retention_for_non_append_only_tables()
801        {
802            tracing::warn!(
803                "Storage retention is enabled for non-append-only table {}. This may lead to stream inconsistency.",
804                table_name
805            );
806            const NOTICE: &str = "Storage retention is enabled for non-append-only table. \
807                                  This may lead to stream inconsistency and unrecoverable \
808                                  node failure if there is any row INSERT/UPDATE/DELETE operation \
809                                  corresponding to the TTLed primary keys";
810            session.notice_to_user(NOTICE);
811        } else {
812            return Err(ErrorCode::NotSupported(
813                "Defining retention seconds on table requires the table to be append only."
814                    .to_owned(),
815                "Use the key words `APPEND ONLY`".to_owned(),
816            )
817            .into());
818        }
819    }
820
821    let materialize =
822        plan_root.gen_table_plan(context, table_name, database_id, schema_id, info, props)?;
823
824    let mut table = materialize.table().clone();
825    table.owner = session.user_id();
826
827    Ok((
828        ensure_sync_log_store_fragment_root(materialize.into()),
829        table,
830    ))
831}
832
833/// Generate stream plan for cdc table based on shared source.
834/// In replace workflow, the `table_id` is the id of the table to be replaced
835/// in create table workflow, the `table_id` is a placeholder will be filled in the Meta
836#[allow(clippy::too_many_arguments)]
837pub(crate) fn gen_create_table_plan_for_cdc_table(
838    context: OptimizerContextRef,
839    source: Arc<SourceCatalog>,
840    external_table_name: String,
841    column_defs: Vec<ColumnDef>,
842    source_watermarks: Vec<SourceWatermark>,
843    mut columns: Vec<ColumnCatalog>,
844    pk_names: Vec<String>,
845    cdc_with_options: WithOptionsSecResolved,
846    mut col_id_gen: ColumnIdGenerator,
847    on_conflict: Option<OnConflict>,
848    with_version_columns: Vec<String>,
849    include_column_options: IncludeOption,
850    table_name: ObjectName,
851    resolved_table_name: String, // table name without schema prefix
852    database_id: DatabaseId,
853    schema_id: SchemaId,
854    table_id: TableId,
855    engine: Engine,
856) -> Result<(PlanRef, TableCatalog)> {
857    let session = context.session_ctx().clone();
858
859    // append additional columns to the end
860    handle_addition_columns(
861        None,
862        &cdc_with_options,
863        include_column_options,
864        &mut columns,
865        true,
866    )?;
867
868    for c in &mut columns {
869        col_id_gen.generate(c)?;
870    }
871
872    let (mut columns, pk_column_ids, _row_id_index) =
873        bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
874
875    let watermark_descs = bind_source_watermark(
876        context.session_ctx(),
877        table_name.real_value(),
878        source_watermarks,
879        &columns,
880    )?;
881
882    // NOTES: In auto schema change, default value is not provided in column definition.
883    bind_sql_column_constraints(
884        context.session_ctx(),
885        table_name.real_value(),
886        &mut columns,
887        &column_defs,
888        &pk_column_ids,
889    )?;
890
891    let definition = context.normalized_sql().to_owned();
892
893    let pk_column_indices = {
894        let mut id_to_idx = HashMap::new();
895        columns.iter().enumerate().for_each(|(idx, c)| {
896            id_to_idx.insert(c.column_id(), idx);
897        });
898        // pk column id must exist in table columns.
899        pk_column_ids
900            .iter()
901            .map(|c| id_to_idx.get(c).copied().unwrap())
902            .collect_vec()
903    };
904    let table_pk = pk_column_indices
905        .iter()
906        .map(|idx| ColumnOrder::new(*idx, OrderType::ascending()))
907        .collect();
908
909    let (options, secret_refs) = cdc_with_options.into_parts();
910
911    let non_generated_column_descs = columns
912        .iter()
913        .filter(|&c| !c.is_generated())
914        .map(|c| c.column_desc.clone())
915        .collect_vec();
916    let non_generated_column_num = non_generated_column_descs.len();
917    let cdc_table_type = ExternalCdcTableType::from_properties(&options);
918    let cdc_table_desc = CdcTableDesc {
919        table_id,
920        source_id: source.id, // id of cdc source streaming job
921        external_table_name: external_table_name.clone(),
922        pk: table_pk,
923        columns: non_generated_column_descs,
924        stream_key: pk_column_indices,
925        connect_properties: options,
926        secret_refs,
927    };
928
929    tracing::debug!(?cdc_table_desc, "create cdc table");
930    let options = build_cdc_scan_options_with_options(context.with_options(), &cdc_table_type)?;
931
932    let logical_scan = LogicalCdcScan::create(
933        external_table_name.clone(),
934        Rc::new(cdc_table_desc),
935        context.clone(),
936        options,
937    );
938
939    let scan_node: LogicalPlanRef = logical_scan.into();
940    let required_cols = FixedBitSet::with_capacity(non_generated_column_num);
941    let plan_root = PlanRoot::new_with_logical_plan(
942        scan_node,
943        RequiredDist::Any,
944        Order::any(),
945        required_cols,
946        vec![],
947    );
948
949    let cdc_table_id_external_table_name = if let ExternalCdcTableType::Postgres = cdc_table_type
950        && let Some(normalized_table_name) =
951            normalize_simple_postgres_quoted_table_name(&external_table_name)
952    {
953        normalized_table_name
954    } else {
955        external_table_name
956    };
957    let cdc_table_id = build_cdc_table_id(source.id, &cdc_table_id_external_table_name);
958    let materialize = plan_root.gen_table_plan(
959        context,
960        resolved_table_name,
961        database_id,
962        schema_id,
963        CreateTableInfo {
964            columns,
965            pk_column_ids,
966            row_id_index: None,
967            watermark_descs,
968            source_catalog: Some((*source).clone()),
969            version: col_id_gen.into_version(),
970        },
971        CreateTableProps {
972            definition,
973            append_only: false,
974            on_conflict: on_conflict.into(),
975            with_version_columns,
976            webhook_info: None,
977            engine,
978        },
979    )?;
980
981    let mut table = materialize.table().clone();
982    table.owner = session.user_id();
983    table.cdc_table_id = Some(cdc_table_id);
984    table.cdc_table_type = Some(cdc_table_type);
985    Ok((
986        ensure_sync_log_store_fragment_root(materialize.into()),
987        table,
988    ))
989}
990
991/// Derive connector properties and normalize `external_table_name` for CDC tables.
992///
993/// Returns (`connector_properties`, `normalized_external_table_name`) where:
994/// - For SQL Server: Normalizes 'db.schema.table' (3 parts) to 'schema.table' (2 parts),
995///   because users can optionally include database name for verification, but it needs to be
996///   stripped to match the format returned by Debezium's `extract_table_name()`.
997/// - For MySQL/Postgres: Returns the original `external_table_name` unchanged.
998fn derive_with_options_for_cdc_table(
999    source_with_properties: &WithOptionsSecResolved,
1000    external_table_name: String,
1001) -> Result<(WithOptionsSecResolved, String)> {
1002    use source::cdc::{MYSQL_CDC_CONNECTOR, POSTGRES_CDC_CONNECTOR, SQL_SERVER_CDC_CONNECTOR};
1003    // we should remove the prefix from `full_table_name`
1004    let source_database_name: &str = source_with_properties
1005        .get("database.name")
1006        .ok_or_else(|| anyhow!("The source with properties does not contain 'database.name'"))?
1007        .as_str();
1008    let mut with_options = source_with_properties.clone();
1009    if let Some(connector) = source_with_properties.get(UPSTREAM_SOURCE_KEY) {
1010        match connector.as_str() {
1011            MYSQL_CDC_CONNECTOR => {
1012                // MySQL doesn't allow '.' in database name and table name, so we can split the
1013                // external table name by '.' to get the table name
1014                let (db_name, table_name) = external_table_name.split_once('.').ok_or_else(|| {
1015                    anyhow!("The upstream table name must contain database name prefix, e.g. 'database.table'")
1016                })?;
1017                // We allow multiple database names in the source definition
1018                if !source_database_name
1019                    .split(',')
1020                    .map(|s| s.trim())
1021                    .any(|name| name == db_name)
1022                {
1023                    return Err(anyhow!(
1024                        "The database name `{}` in the FROM clause is not included in the database name `{}` in source definition",
1025                        db_name,
1026                        source_database_name
1027                    ).into());
1028                }
1029                with_options.insert(DATABASE_NAME_KEY.into(), db_name.into());
1030                with_options.insert(TABLE_NAME_KEY.into(), table_name.into());
1031                // Return original external_table_name unchanged for MySQL
1032                return Ok((with_options, external_table_name));
1033            }
1034            POSTGRES_CDC_CONNECTOR => {
1035                let (schema_name, table_name) =
1036                    parse_postgres_cdc_external_table_name(&external_table_name)?;
1037
1038                // insert 'schema.name' into connect properties
1039                with_options.insert(SCHEMA_NAME_KEY.into(), schema_name);
1040                with_options.insert(TABLE_NAME_KEY.into(), table_name);
1041                // Return original external_table_name unchanged for Postgres
1042                return Ok((with_options, external_table_name));
1043            }
1044            SQL_SERVER_CDC_CONNECTOR => {
1045                // SQL Server external table name must be in one of two formats:
1046                // 1. 'schemaName.tableName' (2 parts) - database is already specified in source
1047                // 2. 'databaseName.schemaName.tableName' (3 parts) - for explicit verification
1048                //
1049                // We do NOT allow single table name (e.g., 't') because:
1050                // - Unlike database name (already in source), schema name is NOT pre-specified
1051                // - User must explicitly provide schema (even if it's 'dbo')
1052                let parts: Vec<&str> = external_table_name.split('.').collect();
1053                let (schema_name, table_name) = match parts.len() {
1054                    3 => {
1055                        // Format: database.schema.table
1056                        // Verify that the database name matches the one in source definition
1057                        let db_name = parts[0];
1058                        let schema_name = parts[1];
1059                        let table_name = parts[2];
1060
1061                        if db_name != source_database_name {
1062                            return Err(anyhow!(
1063                                "The database name '{}' in FROM clause does not match the database name '{}' specified in source definition. \
1064                                 You can either use 'schema.table' format (recommended) or ensure the database name matches.",
1065                                db_name,
1066                                source_database_name
1067                            ).into());
1068                        }
1069                        (schema_name, table_name)
1070                    }
1071                    2 => {
1072                        // Format: schema.table (recommended)
1073                        // Database name is taken from source definition
1074                        let schema_name = parts[0];
1075                        let table_name = parts[1];
1076                        (schema_name, table_name)
1077                    }
1078                    1 => {
1079                        // Format: table only
1080                        // Reject with clear error message
1081                        return Err(anyhow!(
1082                            "Invalid table name format '{}'. For SQL Server CDC, you must specify the schema name. \
1083                             Use 'schema.table' format (e.g., 'dbo.{}') or 'database.schema.table' format (e.g., '{}.dbo.{}').",
1084                            external_table_name,
1085                            external_table_name,
1086                            source_database_name,
1087                            external_table_name
1088                        ).into());
1089                    }
1090                    _ => {
1091                        // Invalid format (4+ parts or empty)
1092                        return Err(anyhow!(
1093                            "Invalid table name format '{}'. Expected 'schema.table' or 'database.schema.table'.",
1094                            external_table_name
1095                        ).into());
1096                    }
1097                };
1098
1099                // Insert schema and table names into connector properties
1100                with_options.insert(SCHEMA_NAME_KEY.into(), schema_name.into());
1101                with_options.insert(TABLE_NAME_KEY.into(), table_name.into());
1102
1103                // Normalize external_table_name to 'schema.table' format
1104                // This ensures consistency with extract_table_name() in message.rs
1105                let normalized_external_table_name = format!("{}.{}", schema_name, table_name);
1106                return Ok((with_options, normalized_external_table_name));
1107            }
1108            _ => {
1109                return Err(RwError::from(anyhow!(
1110                    "connector {} is not supported for cdc table",
1111                    connector
1112                )));
1113            }
1114        };
1115    }
1116    unreachable!("All valid CDC connectors should have returned by now")
1117}
1118
1119/// Parse the schema/table name from the CDC `TABLE` clause.
1120///
1121/// Column names do not need the same parsing here: wildcard schema derivation reads
1122/// them from PostgreSQL catalogs after the exact table has been identified.
1123fn parse_postgres_cdc_external_table_name(external_table_name: &str) -> Result<(String, String)> {
1124    let mut parts = vec![];
1125    let mut current = String::new();
1126    let mut chars = external_table_name.chars().peekable();
1127    let mut in_quote = false;
1128    let mut just_closed_quote = false;
1129
1130    while let Some(ch) = chars.next() {
1131        if in_quote {
1132            if ch == '"' {
1133                if chars.peek() == Some(&'"') {
1134                    current.push('"');
1135                    chars.next();
1136                } else {
1137                    in_quote = false;
1138                    just_closed_quote = true;
1139                }
1140            } else {
1141                current.push(ch);
1142            }
1143        } else {
1144            match ch {
1145                '.' => {
1146                    if current.is_empty() {
1147                        return Err(anyhow!(
1148                            "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1149                            external_table_name
1150                        )
1151                        .into());
1152                    }
1153                    parts.push(std::mem::take(&mut current));
1154                    just_closed_quote = false;
1155                }
1156                '"' if current.is_empty() => {
1157                    in_quote = true;
1158                }
1159                '"' => {
1160                    return Err(anyhow!(
1161                        "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1162                        external_table_name
1163                    )
1164                    .into());
1165                }
1166                _ if just_closed_quote => {
1167                    return Err(anyhow!(
1168                        "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1169                        external_table_name
1170                    )
1171                    .into());
1172                }
1173                _ => current.push(ch),
1174            }
1175        }
1176    }
1177
1178    if in_quote || current.is_empty() {
1179        return Err(anyhow!(
1180            "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
1181            external_table_name
1182        )
1183        .into());
1184    }
1185    parts.push(current);
1186
1187    if let [schema_name, table_name] = parts.as_slice() {
1188        Ok((schema_name.clone(), table_name.clone()))
1189    } else {
1190        Err(
1191            anyhow!("The upstream table name must contain schema name prefix, e.g. 'public.table'")
1192                .into(),
1193        )
1194    }
1195}
1196
1197/// Reject `CREATE TABLE` when a primary-key column is filtered out of Debezium change-event
1198/// values via `debezium.column.exclude.list` or `debezium.column.include.list`.
1199///
1200/// Debezium's column filters only apply to the change-event **value** payload. Message keys are
1201/// always built from the upstream PRIMARY KEY and are not affected. If a PK column is filtered out
1202/// of the value, RisingWave reads NULL for that PK column from the payload, causing silent data
1203/// corruption: UPDATE turns into a fresh INSERT (PK mismatch with the original row) and DELETE
1204/// silently no-ops.
1205///
1206/// Debezium entries are regex patterns matched against the fully qualified column name
1207/// `<namespace>.<table>.<column>`, where namespace is `schema` for Postgres / SQL Server and
1208/// `database` for MySQL.
1209fn reject_pk_filtered_by_debezium_column_filter(
1210    pk_names: &[String],
1211    cdc_with_options: &WithOptionsSecResolved,
1212) -> Result<()> {
1213    const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
1214    const INCLUDE_KEY: &str = "debezium.column.include.list";
1215
1216    let st = SchemaTableName::from_properties(cdc_with_options.as_plaintext());
1217    reject_pk_filtered_by_debezium_column_filter_inner(
1218        pk_names,
1219        &st,
1220        cdc_with_options.get(EXCLUDE_KEY).map(String::as_str),
1221        cdc_with_options.get(INCLUDE_KEY).map(String::as_str),
1222    )
1223}
1224
1225fn reject_pk_filtered_by_debezium_column_filter_inner(
1226    pk_names: &[String],
1227    st: &SchemaTableName,
1228    exclude_list: Option<&str>,
1229    include_list: Option<&str>,
1230) -> Result<()> {
1231    const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
1232    const INCLUDE_KEY: &str = "debezium.column.include.list";
1233
1234    let pk_full_names = pk_names
1235        .iter()
1236        .map(|pk| (pk, format!("{}.{}.{}", st.schema_name, st.table_name, pk)))
1237        .collect_vec();
1238
1239    if let Some(exclude_list) = exclude_list {
1240        let patterns = compile_debezium_column_filter_patterns(EXCLUDE_KEY, exclude_list)?;
1241        for (pk, pk_full_name) in &pk_full_names {
1242            for (pattern, regex) in &patterns {
1243                if regex.is_match(pk_full_name).map_err(|err| {
1244                    ErrorCode::InvalidInputSyntax(format!(
1245                        "failed to evaluate Debezium column filter pattern `{pattern}` in `{EXCLUDE_KEY}`: {}",
1246                        err.as_report()
1247                    ))
1248                })? {
1249                    return Err(ErrorCode::InvalidInputSyntax(format!(
1250                        "primary key column `{pk}` is excluded by `{EXCLUDE_KEY}` pattern \
1251                         `{pattern}`. Excluding a PK column causes silent data corruption: \
1252                         Debezium keeps the PK in the message key but drops it from the payload, \
1253                         so RisingWave cannot match UPDATE/DELETE events against the original row."
1254                    ))
1255                    .into());
1256                }
1257            }
1258        }
1259    }
1260
1261    if let Some(include_list) = include_list {
1262        let patterns = compile_debezium_column_filter_patterns(INCLUDE_KEY, include_list)?;
1263        for (pk, pk_full_name) in &pk_full_names {
1264            let mut included = false;
1265            for (_, regex) in &patterns {
1266                if regex.is_match(pk_full_name).map_err(|err| {
1267                    ErrorCode::InvalidInputSyntax(format!(
1268                        "failed to evaluate Debezium column filter pattern in `{INCLUDE_KEY}`: {}",
1269                        err.as_report()
1270                    ))
1271                })? {
1272                    included = true;
1273                    break;
1274                }
1275            }
1276            if !included {
1277                return Err(ErrorCode::InvalidInputSyntax(format!(
1278                    "primary key column `{pk}` is not included by `{INCLUDE_KEY}`. Omitting a PK \
1279                     column causes silent data corruption: Debezium keeps the PK in the message key \
1280                     but drops it from the payload, so RisingWave cannot match UPDATE/DELETE events \
1281                     against the original row."
1282                ))
1283                .into());
1284            }
1285        }
1286    }
1287
1288    Ok(())
1289}
1290
1291fn compile_debezium_column_filter_patterns(
1292    key: &str,
1293    filter_list: &str,
1294) -> Result<Vec<(String, Regex)>> {
1295    filter_list
1296        .split(',')
1297        .map(str::trim)
1298        .filter(|pattern| !pattern.is_empty())
1299        .map(|pattern| {
1300            let anchored_pattern = format!("(?i:^(?:{pattern})$)");
1301            let regex = Regex::new(&anchored_pattern).map_err(|err| {
1302                ErrorCode::InvalidInputSyntax(format!(
1303                    "invalid Debezium column filter pattern `{pattern}` in `{key}`: {}",
1304                    err.as_report()
1305                ))
1306            })?;
1307            Ok((pattern.to_owned(), regex))
1308        })
1309        .collect()
1310}
1311
1312#[allow(clippy::too_many_arguments)]
1313pub(super) async fn handle_create_table_plan(
1314    handler_args: HandlerArgs,
1315    explain_options: ExplainOptions,
1316    format_encode: Option<FormatEncodeOptions>,
1317    cdc_table_info: Option<CdcTableInfo>,
1318    table_name: &ObjectName,
1319    column_defs: Vec<ColumnDef>,
1320    wildcard_idx: Option<usize>,
1321    constraints: Vec<TableConstraint>,
1322    source_watermarks: Vec<SourceWatermark>,
1323    append_only: bool,
1324    on_conflict: Option<OnConflict>,
1325    with_version_columns: Vec<String>,
1326    include_column_options: IncludeOption,
1327    webhook_info: Option<WebhookSourceInfo>,
1328    engine: Engine,
1329) -> Result<(
1330    PlanRef,
1331    Option<SourceCatalog>,
1332    TableCatalog,
1333    TableJobType,
1334    Option<SourceId>,
1335)> {
1336    let col_id_gen = ColumnIdGenerator::new_initial();
1337    let format_encode = check_create_table_with_source(
1338        &handler_args.with_options,
1339        format_encode,
1340        &include_column_options,
1341        &cdc_table_info,
1342    )?;
1343    let webhook_info = webhook_info
1344        .map(|info| bind_webhook_info(&handler_args.session, &column_defs, info))
1345        .transpose()?;
1346
1347    let props = CreateTableProps {
1348        definition: handler_args.normalized_sql.clone(),
1349        append_only,
1350        on_conflict: on_conflict.into(),
1351        with_version_columns: with_version_columns.clone(),
1352        webhook_info,
1353        engine,
1354    };
1355
1356    let ((plan, source, table), job_type, shared_shource_id) = match (
1357        format_encode,
1358        cdc_table_info.as_ref(),
1359    ) {
1360        (Some(format_encode), None) => (
1361            gen_create_table_plan_with_source(
1362                handler_args,
1363                explain_options,
1364                table_name.clone(),
1365                column_defs,
1366                wildcard_idx,
1367                constraints,
1368                format_encode,
1369                source_watermarks,
1370                col_id_gen,
1371                include_column_options,
1372                props,
1373                SqlColumnStrategy::FollowChecked,
1374            )
1375            .await?,
1376            TableJobType::General,
1377            None,
1378        ),
1379        (None, None) => {
1380            let context = OptimizerContext::new(handler_args, explain_options);
1381            let (plan, table) = gen_create_table_plan(
1382                context,
1383                table_name.clone(),
1384                column_defs,
1385                constraints,
1386                col_id_gen,
1387                source_watermarks,
1388                props,
1389                false,
1390            )?;
1391
1392            ((plan, None, table), TableJobType::General, None)
1393        }
1394
1395        (None, Some(cdc_table)) => {
1396            sanity_check_for_table_on_cdc_source(
1397                append_only,
1398                &column_defs,
1399                &wildcard_idx,
1400                &constraints,
1401                &source_watermarks,
1402            )?;
1403
1404            generated_columns_check_for_cdc_table(&column_defs)?;
1405            not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
1406
1407            let session = &handler_args.session;
1408            let db_name = &session.database();
1409            let user_name = &session.user_name();
1410            let search_path = session.config().search_path();
1411            let (schema_name, resolved_table_name) =
1412                Binder::resolve_schema_qualified_name(db_name, table_name)?;
1413            let (database_id, schema_id) =
1414                session.get_database_and_schema_id_for_create(schema_name.clone())?;
1415
1416            // cdc table cannot be append-only
1417            let (source_schema, source_name) =
1418                Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
1419
1420            let source = {
1421                let catalog_reader = session.env().catalog_reader().read_guard();
1422                let schema_path =
1423                    SchemaPath::new(source_schema.as_deref(), &search_path, user_name);
1424
1425                let (source, _) = catalog_reader.get_source_by_name(
1426                    db_name,
1427                    schema_path,
1428                    source_name.as_str(),
1429                )?;
1430                source.clone()
1431            };
1432            let (cdc_with_options, normalized_external_table_name) =
1433                derive_with_options_for_cdc_table(
1434                    &source.with_properties,
1435                    cdc_table.external_table_name.clone(),
1436                )?;
1437
1438            let (columns, pk_names) = match wildcard_idx {
1439                Some(_) => bind_cdc_table_schema_externally(cdc_with_options.clone()).await?,
1440                None => {
1441                    for column_def in &column_defs {
1442                        for option_def in &column_def.options {
1443                            if let ColumnOption::DefaultValue(_)
1444                            | ColumnOption::DefaultValueInternal { .. } = option_def.option
1445                            {
1446                                return Err(ErrorCode::NotSupported(
1447                                            "Default value for columns defined on the table created from a CDC source".into(),
1448                                            "Remove the default value expression in the column definitions".into(),
1449                                        )
1450                                            .into());
1451                            }
1452                        }
1453                    }
1454
1455                    let (columns, pk_names) =
1456                        bind_cdc_table_schema(&column_defs, &constraints, false)?;
1457                    // read default value definition from external db
1458                    let (options, secret_refs) = cdc_with_options.clone().into_parts();
1459                    let _config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
1460                        .context("failed to extract external table config")?;
1461
1462                    (columns, pk_names)
1463                }
1464            };
1465
1466            // CDC-table branch only: if Debezium column filters remove a PK column from the
1467            // change-event value, RisingWave reads NULL for that PK from the payload and silently
1468            // corrupts downstream (UPDATE turns into a fresh INSERT, DELETE no-ops). Plain
1469            // (non-CDC) tables don't hit this check.
1470            reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1471
1472            let context: OptimizerContextRef =
1473                OptimizerContext::new(handler_args, explain_options).into();
1474            let shared_source_id = source.id;
1475            let (plan, table) = gen_create_table_plan_for_cdc_table(
1476                context,
1477                source,
1478                normalized_external_table_name,
1479                column_defs,
1480                source_watermarks,
1481                columns,
1482                pk_names,
1483                cdc_with_options,
1484                col_id_gen,
1485                on_conflict,
1486                with_version_columns,
1487                include_column_options,
1488                table_name.clone(),
1489                resolved_table_name,
1490                database_id,
1491                schema_id,
1492                TableId::placeholder(),
1493                engine,
1494            )?;
1495
1496            (
1497                (plan, None, table),
1498                TableJobType::SharedCdcSource,
1499                Some(shared_source_id),
1500            )
1501        }
1502        (Some(_), Some(_)) => {
1503            return Err(ErrorCode::NotSupported(
1504                "Data format and encoding format doesn't apply to table created from a CDC source"
1505                    .into(),
1506                "Remove the FORMAT and ENCODE specification".into(),
1507            )
1508            .into());
1509        }
1510    };
1511    Ok((plan, source, table, job_type, shared_shource_id))
1512}
1513
1514// For both table from cdc source and table with cdc connector
1515fn generated_columns_check_for_cdc_table(columns: &Vec<ColumnDef>) -> Result<()> {
1516    let mut found_generated_column = false;
1517    for column in columns {
1518        let mut is_generated = false;
1519
1520        for option_def in &column.options {
1521            if let ColumnOption::GeneratedColumns(_) = option_def.option {
1522                is_generated = true;
1523                break;
1524            }
1525        }
1526
1527        if is_generated {
1528            found_generated_column = true;
1529        } else if found_generated_column {
1530            return Err(ErrorCode::NotSupported(
1531                "Non-generated column found after a generated column.".into(),
1532                "Ensure that all generated columns appear at the end of the cdc table definition."
1533                    .into(),
1534            )
1535            .into());
1536        }
1537    }
1538    Ok(())
1539}
1540
1541// For both table from cdc source and table with cdc connector
1542fn not_null_check_for_cdc_table(
1543    wildcard_idx: &Option<usize>,
1544    column_defs: &Vec<ColumnDef>,
1545) -> Result<()> {
1546    if !wildcard_idx.is_some()
1547        && column_defs.iter().any(|col| {
1548            col.options
1549                .iter()
1550                .any(|opt| matches!(opt.option, ColumnOption::NotNull))
1551        })
1552    {
1553        return Err(ErrorCode::NotSupported(
1554            "CDC table with NOT NULL constraint is not supported".to_owned(),
1555            "Please remove the NOT NULL constraint for columns".to_owned(),
1556        )
1557        .into());
1558    }
1559    Ok(())
1560}
1561
1562// Only for table from cdc source
1563fn sanity_check_for_table_on_cdc_source(
1564    append_only: bool,
1565    column_defs: &Vec<ColumnDef>,
1566    wildcard_idx: &Option<usize>,
1567    constraints: &Vec<TableConstraint>,
1568    source_watermarks: &Vec<SourceWatermark>,
1569) -> Result<()> {
1570    // wildcard cannot be used with column definitions
1571    if wildcard_idx.is_some() && !column_defs.is_empty() {
1572        return Err(ErrorCode::NotSupported(
1573            "wildcard(*) and column definitions cannot be used together".to_owned(),
1574            "Remove the wildcard or column definitions".to_owned(),
1575        )
1576        .into());
1577    }
1578
1579    // cdc table must have primary key constraint or primary key column
1580    if !wildcard_idx.is_some()
1581        && !constraints.iter().any(|c| {
1582            matches!(
1583                c,
1584                TableConstraint::Unique {
1585                    is_primary: true,
1586                    ..
1587                }
1588            )
1589        })
1590        && !column_defs.iter().any(|col| {
1591            col.options
1592                .iter()
1593                .any(|opt| matches!(opt.option, ColumnOption::Unique { is_primary: true }))
1594        })
1595    {
1596        return Err(ErrorCode::NotSupported(
1597            "CDC table without primary key constraint is not supported".to_owned(),
1598            "Please define a primary key".to_owned(),
1599        )
1600        .into());
1601    }
1602
1603    if append_only {
1604        return Err(ErrorCode::NotSupported(
1605            "append only modifier on the table created from a CDC source".into(),
1606            "Remove the APPEND ONLY clause".into(),
1607        )
1608        .into());
1609    }
1610
1611    if !source_watermarks.is_empty()
1612        && source_watermarks
1613            .iter()
1614            .any(|watermark| !watermark.with_ttl)
1615    {
1616        return Err(ErrorCode::NotSupported(
1617            "non-TTL watermark defined on the table created from a CDC source".into(),
1618            "Use `WATERMARK ... WITH TTL` instead.".into(),
1619        )
1620        .into());
1621    }
1622
1623    Ok(())
1624}
1625
1626/// Derive schema for cdc table when create a new Table or alter an existing Table
1627async fn bind_cdc_table_schema_externally(
1628    cdc_with_options: WithOptionsSecResolved,
1629) -> Result<(Vec<ColumnCatalog>, Vec<String>)> {
1630    // read cdc table schema from external db or parsing the schema from SQL definitions
1631    let (options, secret_refs) = cdc_with_options.into_parts();
1632    let config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
1633        .context("failed to extract external table config")?;
1634
1635    let table = ExternalTableImpl::connect(config)
1636        .await
1637        .context("failed to auto derive table schema")?;
1638
1639    Ok((
1640        table
1641            .column_descs()
1642            .iter()
1643            .cloned()
1644            .map(|column_desc| ColumnCatalog {
1645                column_desc,
1646                is_hidden: false,
1647            })
1648            .collect(),
1649        table.pk_names().clone(),
1650    ))
1651}
1652
1653/// Derive schema for cdc table when create a new Table or alter an existing Table
1654fn bind_cdc_table_schema(
1655    column_defs: &Vec<ColumnDef>,
1656    constraints: &Vec<TableConstraint>,
1657    is_for_replace_plan: bool,
1658) -> Result<(Vec<ColumnCatalog>, Vec<String>)> {
1659    let columns = bind_sql_columns(column_defs, is_for_replace_plan)?;
1660
1661    let pk_names = bind_sql_pk_names(column_defs, bind_table_constraints(constraints)?)?;
1662    Ok((columns, pk_names))
1663}
1664
1665#[allow(clippy::too_many_arguments)]
1666pub async fn handle_create_table(
1667    handler_args: HandlerArgs,
1668    table_name: ObjectName,
1669    column_defs: Vec<ColumnDef>,
1670    wildcard_idx: Option<usize>,
1671    constraints: Vec<TableConstraint>,
1672    if_not_exists: bool,
1673    format_encode: Option<FormatEncodeOptions>,
1674    source_watermarks: Vec<SourceWatermark>,
1675    append_only: bool,
1676    on_conflict: Option<OnConflict>,
1677    with_version_columns: Vec<String>,
1678    cdc_table_info: Option<CdcTableInfo>,
1679    include_column_options: IncludeOption,
1680    webhook_info: Option<WebhookSourceInfo>,
1681    ast_engine: risingwave_sqlparser::ast::Engine,
1682) -> Result<RwPgResponse> {
1683    let session = handler_args.session.clone();
1684
1685    if append_only {
1686        session.notice_to_user("APPEND ONLY TABLE is currently an experimental feature.");
1687    }
1688
1689    session.check_cluster_limits().await?;
1690
1691    let engine = match ast_engine {
1692        risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1693        risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1694    };
1695
1696    if let Either::Right(resp) = session.check_relation_name_duplicated(
1697        table_name.clone(),
1698        StatementType::CREATE_TABLE,
1699        if_not_exists,
1700    )? {
1701        return Ok(resp);
1702    }
1703
1704    let (graph, source, hummock_table, job_type, shared_source_id) = {
1705        let (plan, source, table, job_type, shared_source_id) = handle_create_table_plan(
1706            handler_args.clone(),
1707            ExplainOptions::default(),
1708            format_encode,
1709            cdc_table_info,
1710            &table_name,
1711            column_defs.clone(),
1712            wildcard_idx,
1713            constraints.clone(),
1714            source_watermarks,
1715            append_only,
1716            on_conflict,
1717            with_version_columns,
1718            include_column_options,
1719            webhook_info,
1720            engine,
1721        )
1722        .await?;
1723        tracing::trace!("table_plan: {:?}", plan.explain_to_string());
1724
1725        let graph = build_graph(plan, Some(GraphJobType::Table))?;
1726
1727        (graph, source, table, job_type, shared_source_id)
1728    };
1729
1730    tracing::trace!(
1731        "name={}, graph=\n{}",
1732        table_name,
1733        serde_json::to_string_pretty(&graph).unwrap()
1734    );
1735
1736    let dependencies = shared_source_id
1737        .map(|id| HashSet::from([id.as_object_id()]))
1738        .unwrap_or_default();
1739
1740    // Handle engine
1741    match engine {
1742        Engine::Hummock => {
1743            let catalog_writer = session.catalog_writer()?;
1744            let action = match job_type {
1745                TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1746                _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1747            };
1748            execute_with_long_running_notification(
1749                catalog_writer.create_table(
1750                    source.map(|s| s.to_prost()),
1751                    hummock_table.to_prost(),
1752                    graph,
1753                    job_type,
1754                    if_not_exists,
1755                    dependencies,
1756                ),
1757                &session,
1758                "CREATE TABLE",
1759                action,
1760            )
1761            .await?;
1762        }
1763        Engine::Iceberg => {
1764            let hummock_table_name = hummock_table.name.clone();
1765            session.create_staging_table(hummock_table.clone());
1766            let res = Box::pin(create_iceberg_engine_table(
1767                session.clone(),
1768                handler_args,
1769                source.map(|s| s.to_prost()),
1770                hummock_table,
1771                graph,
1772                table_name,
1773                job_type,
1774                if_not_exists,
1775            ))
1776            .await;
1777            session.drop_staging_table(&hummock_table_name);
1778            res?
1779        }
1780    }
1781
1782    Ok(PgResponse::empty_result(StatementType::CREATE_TABLE))
1783}
1784
1785/// Iceberg table engine is composed of hummock table, iceberg sink and iceberg source.
1786///
1787/// 1. fetch iceberg engine options from the meta node. Or use iceberg engine connection provided by users.
1788/// 2. create a hummock table
1789/// 3. create an iceberg sink
1790/// 4. create an iceberg source
1791///
1792/// See <https://github.com/risingwavelabs/risingwave/issues/21586> for an architecture diagram.
1793#[allow(clippy::too_many_arguments)]
1794pub async fn create_iceberg_engine_table(
1795    session: Arc<SessionImpl>,
1796    handler_args: HandlerArgs,
1797    mut source: Option<PbSource>,
1798    table: TableCatalog,
1799    graph: StreamFragmentGraph,
1800    table_name: ObjectName,
1801    job_type: PbTableJobType,
1802    if_not_exists: bool,
1803) -> Result<()> {
1804    let rw_db_name = session
1805        .env()
1806        .catalog_reader()
1807        .read_guard()
1808        .get_database_by_id(table.database_id)?
1809        .name()
1810        .to_owned();
1811    let rw_schema_name = session
1812        .env()
1813        .catalog_reader()
1814        .read_guard()
1815        .get_schema_by_id(table.database_id, table.schema_id)?
1816        .name()
1817        .clone();
1818    let iceberg_catalog_name = rw_db_name.clone();
1819    let iceberg_database_name = rw_schema_name.clone();
1820    let iceberg_table_name = table_name.0.last().unwrap().real_value();
1821
1822    let iceberg_engine_connection: String = session.config().iceberg_engine_connection();
1823    let sink_decouple = session.config().sink_decouple();
1824    if matches!(sink_decouple, SinkDecouple::Disable) {
1825        bail!(
1826            "Iceberg engine table only supports with sink decouple, try `set sink_decouple = true` to resolve it"
1827        );
1828    }
1829
1830    let mut connection_ref = BTreeMap::new();
1831    let with_common = if iceberg_engine_connection.is_empty() {
1832        bail!("to use iceberg engine table, the variable `iceberg_engine_connection` must be set.");
1833    } else {
1834        let parts: Vec<&str> = iceberg_engine_connection.split('.').collect();
1835        assert_eq!(parts.len(), 2);
1836        let connection_catalog =
1837            session.get_connection_by_name(Some(parts[0].to_owned()), parts[1])?;
1838        if let ConnectionInfo::ConnectionParams(params) = &connection_catalog.info {
1839            if params.connection_type == ConnectionType::Iceberg as i32 {
1840                // With iceberg engine connection:
1841                connection_ref.insert(
1842                    "connection".to_owned(),
1843                    ConnectionRefValue {
1844                        connection_name: ObjectName::from(vec![
1845                            Ident::from(parts[0]),
1846                            Ident::from(parts[1]),
1847                        ]),
1848                    },
1849                );
1850
1851                let mut with_common = BTreeMap::new();
1852                with_common.insert("connector".to_owned(), "iceberg".to_owned());
1853                with_common.insert("database.name".to_owned(), iceberg_database_name);
1854                with_common.insert("table.name".to_owned(), iceberg_table_name);
1855
1856                let hosted_catalog = params
1857                    .properties
1858                    .get("hosted_catalog")
1859                    .map(|s| s.eq_ignore_ascii_case("true"))
1860                    .unwrap_or(false);
1861                if hosted_catalog {
1862                    let meta_client = session.env().meta_client();
1863                    let meta_store_endpoint = meta_client.get_meta_store_endpoint().await?;
1864
1865                    let meta_store_endpoint =
1866                        url::Url::parse(&meta_store_endpoint).map_err(|_| {
1867                            ErrorCode::InternalError(
1868                                "failed to parse the meta store endpoint".to_owned(),
1869                            )
1870                        })?;
1871                    let meta_store_backend = meta_store_endpoint.scheme().to_owned();
1872                    let meta_store_user = meta_store_endpoint.username().to_owned();
1873                    let meta_store_password = match meta_store_endpoint.password() {
1874                        Some(password) => percent_decode_str(password)
1875                            .decode_utf8()
1876                            .map_err(|_| {
1877                                ErrorCode::InternalError(
1878                                    "failed to parse password from meta store endpoint".to_owned(),
1879                                )
1880                            })?
1881                            .into_owned(),
1882                        None => "".to_owned(),
1883                    };
1884                    let meta_store_host = meta_store_endpoint
1885                        .host_str()
1886                        .ok_or_else(|| {
1887                            ErrorCode::InternalError(
1888                                "failed to parse host from meta store endpoint".to_owned(),
1889                            )
1890                        })?
1891                        .to_owned();
1892                    let meta_store_port = meta_store_endpoint.port().ok_or_else(|| {
1893                        ErrorCode::InternalError(
1894                            "failed to parse port from meta store endpoint".to_owned(),
1895                        )
1896                    })?;
1897                    let meta_store_database = meta_store_endpoint
1898                        .path()
1899                        .trim_start_matches('/')
1900                        .to_owned();
1901
1902                    let Ok(meta_backend) = MetaBackend::from_str(&meta_store_backend, true) else {
1903                        bail!("failed to parse meta backend: {}", meta_store_backend);
1904                    };
1905
1906                    let catalog_uri = match meta_backend {
1907                        MetaBackend::Postgres => {
1908                            format!(
1909                                "jdbc:postgresql://{}:{}/{}",
1910                                meta_store_host, meta_store_port, meta_store_database
1911                            )
1912                        }
1913                        MetaBackend::Mysql => {
1914                            format!(
1915                                "jdbc:mysql://{}:{}/{}",
1916                                meta_store_host, meta_store_port, meta_store_database
1917                            )
1918                        }
1919                        MetaBackend::Sqlite | MetaBackend::Sql | MetaBackend::Mem => {
1920                            bail!(
1921                                "Unsupported meta backend for iceberg engine table: {}",
1922                                meta_store_backend
1923                            );
1924                        }
1925                    };
1926
1927                    with_common.insert("catalog.type".to_owned(), "jdbc".to_owned());
1928                    with_common.insert("catalog.uri".to_owned(), catalog_uri);
1929                    with_common.insert("catalog.jdbc.user".to_owned(), meta_store_user);
1930                    with_common.insert("catalog.jdbc.password".to_owned(), meta_store_password);
1931                    with_common.insert("catalog.name".to_owned(), iceberg_catalog_name);
1932                }
1933
1934                with_common
1935            } else {
1936                return Err(RwError::from(ErrorCode::InvalidParameterValue(
1937                    "Only iceberg connection could be used in iceberg engine".to_owned(),
1938                )));
1939            }
1940        } else {
1941            return Err(RwError::from(ErrorCode::InvalidParameterValue(
1942                "Private Link Service has been deprecated. Please create a new connection instead."
1943                    .to_owned(),
1944            )));
1945        }
1946    };
1947
1948    // Iceberg sinks require a primary key, if none is provided, we will use the _row_id column
1949    // Fetch primary key from columns
1950    let mut pks = table
1951        .pk_column_names()
1952        .iter()
1953        .map(|c| c.to_string())
1954        .collect::<Vec<String>>();
1955
1956    // For the table without primary key. We will use `_row_id` as primary key.
1957    if pks.len() == 1 && pks[0].eq(ROW_ID_COLUMN_NAME) {
1958        pks = vec![RISINGWAVE_ICEBERG_ROW_ID.to_owned()];
1959    }
1960
1961    let sink_from = CreateSink::From(table_name.clone());
1962
1963    let mut sink_name = table_name.clone();
1964    *sink_name.0.last_mut().unwrap() = Ident::from(
1965        (ICEBERG_SINK_PREFIX.to_owned() + &sink_name.0.last().unwrap().real_value()).as_str(),
1966    );
1967    let create_sink_stmt = CreateSinkStatement {
1968        or_replace: false,
1969        if_not_exists: false,
1970        sink_name,
1971        with_properties: WithProperties(vec![]),
1972        sink_from,
1973        columns: vec![],
1974        emit_mode: None,
1975        sink_schema: None,
1976        into_table_name: None,
1977    };
1978
1979    let mut sink_handler_args = handler_args.clone();
1980
1981    let mut sink_with = with_common.clone();
1982
1983    let enable_pk_index = handler_args
1984        .with_options
1985        .get(ENABLE_PK_INDEX)
1986        .is_some_and(|val| val.eq_ignore_ascii_case("true"));
1987
1988    // Iceberg with pk index does not support auto schema change yet.
1989    if !enable_pk_index {
1990        sink_with.insert(AUTO_SCHEMA_CHANGE_KEY.to_owned(), "true".to_owned());
1991    }
1992
1993    if table.append_only {
1994        sink_with.insert("type".to_owned(), "append-only".to_owned());
1995    } else {
1996        sink_with.insert("type".to_owned(), "upsert".to_owned());
1997        if !enable_pk_index {
1998            sink_with.insert("primary_key".to_owned(), pks.join(","));
1999        }
2000    }
2001    // sink_with.insert(SINK_SNAPSHOT_OPTION.to_owned(), "false".to_owned());
2002    //
2003    // Note: in theory, we don't need to backfill from the table to the sink,
2004    // but we don't have atomic DDL now https://github.com/risingwavelabs/risingwave/issues/21863
2005    // so it may have potential data loss problem on the first barrier.
2006    //
2007    // For non-append-only table, we can always solve it by the initial sink with backfill, since
2008    // data will be present in hummock table.
2009    //
2010    // For append-only table, we need to be more careful.
2011    //
2012    // The possible cases for a table:
2013    // - For table without connector: it doesn't matter, since there's no data before the table is created
2014    // - For table with connector: we workarounded it by setting SOURCE_RATE_LIMIT to 0
2015    //   + If we support blocking DDL for table with connector, we need to be careful.
2016    // - For table with an upstream job: Specifically, CDC table from shared CDC source.
2017    //   + Data may come from both upstream connector, and CDC table backfill, so we need to pause both of them.
2018    //   + For now we don't support APPEND ONLY CDC table, so it's safe.
2019    let commit_checkpoint_interval = handler_args
2020        .with_options
2021        .get(COMMIT_CHECKPOINT_INTERVAL)
2022        .map(|v| v.to_owned())
2023        .unwrap_or_else(|| "60".to_owned());
2024    let commit_checkpoint_interval = commit_checkpoint_interval.parse::<u32>().map_err(|_| {
2025        ErrorCode::InvalidInputSyntax(format!(
2026            "commit_checkpoint_interval must be greater than 0: {}",
2027            commit_checkpoint_interval
2028        ))
2029    })?;
2030
2031    if commit_checkpoint_interval == 0 {
2032        bail!("commit_checkpoint_interval must be greater than 0");
2033    }
2034
2035    // remove commit_checkpoint_interval from source options, otherwise it will be considered as an unknown field.
2036    source
2037        .as_mut()
2038        .map(|x| x.with_properties.remove(COMMIT_CHECKPOINT_INTERVAL));
2039
2040    let sink_decouple = session.config().sink_decouple();
2041    if matches!(sink_decouple, SinkDecouple::Disable) && commit_checkpoint_interval > 1 {
2042        bail!(
2043            "config conflict: `commit_checkpoint_interval` larger than 1 means that sink decouple must be enabled, but session config sink_decouple is disabled"
2044        )
2045    }
2046
2047    sink_with.insert(
2048        COMMIT_CHECKPOINT_INTERVAL.to_owned(),
2049        commit_checkpoint_interval.to_string(),
2050    );
2051    sink_with.insert("create_table_if_not_exists".to_owned(), "true".to_owned());
2052
2053    sink_with.insert("is_exactly_once".to_owned(), "true".to_owned());
2054
2055    if let Some(enable_compaction) = handler_args.with_options.get(ENABLE_COMPACTION) {
2056        match enable_compaction.to_lowercase().as_str() {
2057            "true" => {
2058                sink_with.insert(ENABLE_COMPACTION.to_owned(), "true".to_owned());
2059            }
2060            "false" => {
2061                sink_with.insert(ENABLE_COMPACTION.to_owned(), "false".to_owned());
2062            }
2063            _ => {
2064                return Err(ErrorCode::InvalidInputSyntax(format!(
2065                    "enable_compaction must be true or false: {}",
2066                    enable_compaction
2067                ))
2068                .into());
2069            }
2070        }
2071
2072        // remove enable_compaction from source options, otherwise it will be considered as an unknown field.
2073        source
2074            .as_mut()
2075            .map(|x| x.with_properties.remove("enable_compaction"));
2076    } else {
2077        sink_with.insert(ENABLE_COMPACTION.to_owned(), "true".to_owned());
2078    }
2079
2080    if let Some(compaction_interval_sec) = handler_args.with_options.get(COMPACTION_INTERVAL_SEC) {
2081        let compaction_interval_sec = compaction_interval_sec.parse::<u64>().map_err(|_| {
2082            ErrorCode::InvalidInputSyntax(format!(
2083                "compaction_interval_sec must be greater than 0: {}",
2084                commit_checkpoint_interval
2085            ))
2086        })?;
2087        if compaction_interval_sec == 0 {
2088            bail!("compaction_interval_sec must be greater than 0");
2089        }
2090        sink_with.insert(
2091            "compaction_interval_sec".to_owned(),
2092            compaction_interval_sec.to_string(),
2093        );
2094        // remove compaction_interval_sec from source options, otherwise it will be considered as an unknown field.
2095        source
2096            .as_mut()
2097            .map(|x| x.with_properties.remove("compaction_interval_sec"));
2098    }
2099
2100    let has_enabled_snapshot_expiration = if let Some(enable_snapshot_expiration) =
2101        handler_args.with_options.get(ENABLE_SNAPSHOT_EXPIRATION)
2102    {
2103        // remove enable_snapshot_expiration from source options, otherwise it will be considered as an unknown field.
2104        source
2105            .as_mut()
2106            .map(|x| x.with_properties.remove(ENABLE_SNAPSHOT_EXPIRATION));
2107        match enable_snapshot_expiration.to_lowercase().as_str() {
2108            "true" => {
2109                sink_with.insert(ENABLE_SNAPSHOT_EXPIRATION.to_owned(), "true".to_owned());
2110                true
2111            }
2112            "false" => {
2113                sink_with.insert(ENABLE_SNAPSHOT_EXPIRATION.to_owned(), "false".to_owned());
2114                false
2115            }
2116            _ => {
2117                return Err(ErrorCode::InvalidInputSyntax(format!(
2118                    "enable_snapshot_expiration must be true or false: {}",
2119                    enable_snapshot_expiration
2120                ))
2121                .into());
2122            }
2123        }
2124    } else {
2125        sink_with.insert(ENABLE_SNAPSHOT_EXPIRATION.to_owned(), "true".to_owned());
2126        true
2127    };
2128
2129    if has_enabled_snapshot_expiration {
2130        // configuration for snapshot expiration
2131        if let Some(snapshot_expiration_retain_last) = handler_args
2132            .with_options
2133            .get(SNAPSHOT_EXPIRATION_RETAIN_LAST)
2134        {
2135            sink_with.insert(
2136                SNAPSHOT_EXPIRATION_RETAIN_LAST.to_owned(),
2137                snapshot_expiration_retain_last.to_owned(),
2138            );
2139            // remove snapshot_expiration_retain_last from source options, otherwise it will be considered as an unknown field.
2140            source
2141                .as_mut()
2142                .map(|x| x.with_properties.remove(SNAPSHOT_EXPIRATION_RETAIN_LAST));
2143        }
2144
2145        if let Some(snapshot_expiration_max_age) = handler_args
2146            .with_options
2147            .get(SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS)
2148        {
2149            sink_with.insert(
2150                SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS.to_owned(),
2151                snapshot_expiration_max_age.to_owned(),
2152            );
2153            // remove snapshot_expiration_max_age from source options, otherwise it will be considered as an unknown field.
2154            source
2155                .as_mut()
2156                .map(|x| x.with_properties.remove(SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS));
2157        }
2158
2159        if let Some(snapshot_expiration_clear_expired_files) = handler_args
2160            .with_options
2161            .get(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES)
2162        {
2163            sink_with.insert(
2164                SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES.to_owned(),
2165                snapshot_expiration_clear_expired_files.to_owned(),
2166            );
2167            // remove snapshot_expiration_clear_expired_files from source options, otherwise it will be considered as an unknown field.
2168            source.as_mut().map(|x| {
2169                x.with_properties
2170                    .remove(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES)
2171            });
2172        }
2173
2174        if let Some(snapshot_expiration_clear_expired_meta_data) = handler_args
2175            .with_options
2176            .get(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA)
2177        {
2178            sink_with.insert(
2179                SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA.to_owned(),
2180                snapshot_expiration_clear_expired_meta_data.to_owned(),
2181            );
2182            // remove snapshot_expiration_clear_expired_meta_data from source options, otherwise it will be considered as an unknown field.
2183            source.as_mut().map(|x| {
2184                x.with_properties
2185                    .remove(SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA)
2186            });
2187        }
2188    }
2189
2190    if let Some(format_version) = handler_args.with_options.get(FORMAT_VERSION) {
2191        let format_version = format_version.parse::<u8>().map_err(|_| {
2192            ErrorCode::InvalidInputSyntax(format!(
2193                "format_version must be 1, 2 or 3: {}",
2194                format_version
2195            ))
2196        })?;
2197        if format_version != 1 && format_version != 2 && format_version != 3 {
2198            bail!("format_version must be 1, 2 or 3");
2199        }
2200        sink_with.insert(FORMAT_VERSION.to_owned(), format_version.to_string());
2201
2202        // remove format_version from source options, otherwise it will be considered as an unknown field.
2203        source
2204            .as_mut()
2205            .map(|x| x.with_properties.remove(FORMAT_VERSION));
2206    }
2207
2208    if let Some(write_mode) = handler_args.with_options.get(WRITE_MODE) {
2209        let write_mode = IcebergWriteMode::try_from(write_mode.as_str()).map_err(|_| {
2210            ErrorCode::InvalidInputSyntax(format!(
2211                "invalid write_mode: {}, must be one of: {}, {}",
2212                write_mode, ICEBERG_WRITE_MODE_MERGE_ON_READ, ICEBERG_WRITE_MODE_COPY_ON_WRITE
2213            ))
2214        })?;
2215
2216        match write_mode {
2217            IcebergWriteMode::MergeOnRead => {
2218                sink_with.insert(WRITE_MODE.to_owned(), write_mode.as_str().to_owned());
2219            }
2220
2221            IcebergWriteMode::CopyOnWrite => {
2222                if table.append_only {
2223                    return Err(ErrorCode::NotSupported(
2224                        "COPY ON WRITE is not supported for append-only iceberg table".to_owned(),
2225                        "Please use MERGE ON READ instead".to_owned(),
2226                    )
2227                    .into());
2228                }
2229
2230                sink_with.insert(WRITE_MODE.to_owned(), write_mode.as_str().to_owned());
2231            }
2232        }
2233
2234        // remove write_mode from source options, otherwise it will be considered as an unknown field.
2235        source
2236            .as_mut()
2237            .map(|x| x.with_properties.remove("write_mode"));
2238    } else {
2239        sink_with.insert(
2240            WRITE_MODE.to_owned(),
2241            ICEBERG_WRITE_MODE_MERGE_ON_READ.to_owned(),
2242        );
2243    }
2244
2245    if let Some(enable_pk_index) = handler_args.with_options.get(ENABLE_PK_INDEX) {
2246        match enable_pk_index.to_lowercase().as_str() {
2247            "true" => {
2248                sink_with.insert(ENABLE_PK_INDEX.to_owned(), "true".to_owned());
2249            }
2250            "false" => {
2251                sink_with.insert(ENABLE_PK_INDEX.to_owned(), "false".to_owned());
2252            }
2253            _ => {
2254                return Err(ErrorCode::InvalidInputSyntax(format!(
2255                    "enable_pk_index must be true or false: {}",
2256                    enable_pk_index
2257                ))
2258                .into());
2259            }
2260        }
2261
2262        // remove enable_pk_index from source options, otherwise it will be considered as an unknown field.
2263        source
2264            .as_mut()
2265            .map(|x| x.with_properties.remove(ENABLE_PK_INDEX));
2266    }
2267
2268    if let Some(max_snapshots_num_before_compaction) =
2269        handler_args.with_options.get(COMPACTION_MAX_SNAPSHOTS_NUM)
2270    {
2271        let max_snapshots_num_before_compaction = max_snapshots_num_before_compaction
2272            .parse::<u32>()
2273            .map_err(|_| {
2274                ErrorCode::InvalidInputSyntax(format!(
2275                    "{} must be greater than 0: {}",
2276                    COMPACTION_MAX_SNAPSHOTS_NUM, max_snapshots_num_before_compaction
2277                ))
2278            })?;
2279
2280        if max_snapshots_num_before_compaction == 0 {
2281            bail!(format!(
2282                "{} must be greater than 0",
2283                COMPACTION_MAX_SNAPSHOTS_NUM
2284            ));
2285        }
2286
2287        sink_with.insert(
2288            COMPACTION_MAX_SNAPSHOTS_NUM.to_owned(),
2289            max_snapshots_num_before_compaction.to_string(),
2290        );
2291
2292        // remove from source options, otherwise it will be considered as an unknown field.
2293        source
2294            .as_mut()
2295            .map(|x| x.with_properties.remove(COMPACTION_MAX_SNAPSHOTS_NUM));
2296    }
2297
2298    if let Some(small_files_threshold_mb) = handler_args
2299        .with_options
2300        .get(COMPACTION_SMALL_FILES_THRESHOLD_MB)
2301    {
2302        let small_files_threshold_mb = small_files_threshold_mb.parse::<u64>().map_err(|_| {
2303            ErrorCode::InvalidInputSyntax(format!(
2304                "{} must be greater than 0: {}",
2305                COMPACTION_SMALL_FILES_THRESHOLD_MB, small_files_threshold_mb
2306            ))
2307        })?;
2308        if small_files_threshold_mb == 0 {
2309            bail!(format!(
2310                "{} must be a greater than 0",
2311                COMPACTION_SMALL_FILES_THRESHOLD_MB
2312            ));
2313        }
2314        sink_with.insert(
2315            COMPACTION_SMALL_FILES_THRESHOLD_MB.to_owned(),
2316            small_files_threshold_mb.to_string(),
2317        );
2318
2319        // remove from source options, otherwise it will be considered as an unknown field.
2320        source.as_mut().map(|x| {
2321            x.with_properties
2322                .remove(COMPACTION_SMALL_FILES_THRESHOLD_MB)
2323        });
2324    }
2325
2326    if let Some(delete_files_count_threshold) = handler_args
2327        .with_options
2328        .get(COMPACTION_DELETE_FILES_COUNT_THRESHOLD)
2329    {
2330        let delete_files_count_threshold =
2331            delete_files_count_threshold.parse::<usize>().map_err(|_| {
2332                ErrorCode::InvalidInputSyntax(format!(
2333                    "{} must be greater than 0: {}",
2334                    COMPACTION_DELETE_FILES_COUNT_THRESHOLD, delete_files_count_threshold
2335                ))
2336            })?;
2337        if delete_files_count_threshold == 0 {
2338            bail!(format!(
2339                "{} must be greater than 0",
2340                COMPACTION_DELETE_FILES_COUNT_THRESHOLD
2341            ));
2342        }
2343        sink_with.insert(
2344            COMPACTION_DELETE_FILES_COUNT_THRESHOLD.to_owned(),
2345            delete_files_count_threshold.to_string(),
2346        );
2347
2348        // remove from source options, otherwise it will be considered as an unknown field.
2349        source.as_mut().map(|x| {
2350            x.with_properties
2351                .remove(COMPACTION_DELETE_FILES_COUNT_THRESHOLD)
2352        });
2353    }
2354
2355    if let Some(trigger_snapshot_count) = handler_args
2356        .with_options
2357        .get(COMPACTION_TRIGGER_SNAPSHOT_COUNT)
2358    {
2359        let trigger_snapshot_count = trigger_snapshot_count.parse::<usize>().map_err(|_| {
2360            ErrorCode::InvalidInputSyntax(format!(
2361                "{} must be greater than 0: {}",
2362                COMPACTION_TRIGGER_SNAPSHOT_COUNT, trigger_snapshot_count
2363            ))
2364        })?;
2365        if trigger_snapshot_count == 0 {
2366            bail!(format!(
2367                "{} must be greater than 0",
2368                COMPACTION_TRIGGER_SNAPSHOT_COUNT
2369            ));
2370        }
2371        sink_with.insert(
2372            COMPACTION_TRIGGER_SNAPSHOT_COUNT.to_owned(),
2373            trigger_snapshot_count.to_string(),
2374        );
2375
2376        // remove from source options, otherwise it will be considered as an unknown field.
2377        source
2378            .as_mut()
2379            .map(|x| x.with_properties.remove(COMPACTION_TRIGGER_SNAPSHOT_COUNT));
2380    }
2381
2382    if let Some(target_file_size_mb) = handler_args
2383        .with_options
2384        .get(COMPACTION_TARGET_FILE_SIZE_MB)
2385    {
2386        let target_file_size_mb = target_file_size_mb.parse::<u64>().map_err(|_| {
2387            ErrorCode::InvalidInputSyntax(format!(
2388                "{} must be greater than 0: {}",
2389                COMPACTION_TARGET_FILE_SIZE_MB, target_file_size_mb
2390            ))
2391        })?;
2392        if target_file_size_mb == 0 {
2393            bail!(format!(
2394                "{} must be greater than 0",
2395                COMPACTION_TARGET_FILE_SIZE_MB
2396            ));
2397        }
2398        sink_with.insert(
2399            COMPACTION_TARGET_FILE_SIZE_MB.to_owned(),
2400            target_file_size_mb.to_string(),
2401        );
2402        // remove from source options, otherwise it will be considered as an unknown field.
2403        source
2404            .as_mut()
2405            .map(|x| x.with_properties.remove(COMPACTION_TARGET_FILE_SIZE_MB));
2406    }
2407
2408    if let Some(compaction_type) = handler_args.with_options.get(COMPACTION_TYPE) {
2409        let compaction_type = CompactionType::try_from(compaction_type.as_str()).map_err(|_| {
2410            ErrorCode::InvalidInputSyntax(format!(
2411                "invalid compaction_type: {}, must be one of {:?}",
2412                compaction_type,
2413                [
2414                    CompactionType::Full,
2415                    CompactionType::SmallFiles,
2416                    CompactionType::FilesWithDelete
2417                ]
2418            ))
2419        })?;
2420
2421        sink_with.insert(
2422            COMPACTION_TYPE.to_owned(),
2423            compaction_type.as_str().to_owned(),
2424        );
2425
2426        // remove from source options, otherwise it will be considered as an unknown field.
2427        source
2428            .as_mut()
2429            .map(|x| x.with_properties.remove(COMPACTION_TYPE));
2430    }
2431
2432    if let Some(write_parquet_compression) = handler_args
2433        .with_options
2434        .get(COMPACTION_WRITE_PARQUET_COMPRESSION)
2435    {
2436        sink_with.insert(
2437            COMPACTION_WRITE_PARQUET_COMPRESSION.to_owned(),
2438            write_parquet_compression.to_owned(),
2439        );
2440        // remove from source options, otherwise it will be considered as an unknown field.
2441        source.as_mut().map(|x| {
2442            x.with_properties
2443                .remove(COMPACTION_WRITE_PARQUET_COMPRESSION)
2444        });
2445    }
2446
2447    if let Some(write_parquet_max_row_group_rows) = handler_args
2448        .with_options
2449        .get(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS)
2450    {
2451        let write_parquet_max_row_group_rows = write_parquet_max_row_group_rows
2452            .parse::<usize>()
2453            .map_err(|_| {
2454                ErrorCode::InvalidInputSyntax(format!(
2455                    "{} must be a positive integer: {}",
2456                    COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS, write_parquet_max_row_group_rows
2457                ))
2458            })?;
2459        if write_parquet_max_row_group_rows == 0 {
2460            bail!(format!(
2461                "{} must be greater than 0",
2462                COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS
2463            ));
2464        }
2465        sink_with.insert(
2466            COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS.to_owned(),
2467            write_parquet_max_row_group_rows.to_string(),
2468        );
2469        // remove from source options, otherwise it will be considered as an unknown field.
2470        source.as_mut().map(|x| {
2471            x.with_properties
2472                .remove(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS)
2473        });
2474    }
2475
2476    if let Some(write_parquet_max_row_group_bytes) = handler_args
2477        .with_options
2478        .get(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES)
2479    {
2480        let write_parquet_max_row_group_bytes = write_parquet_max_row_group_bytes
2481            .parse::<usize>()
2482            .map_err(|_| {
2483                ErrorCode::InvalidInputSyntax(format!(
2484                    "{} must be a positive integer: {}",
2485                    COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES, write_parquet_max_row_group_bytes
2486                ))
2487            })?;
2488        if write_parquet_max_row_group_bytes == 0 {
2489            bail!(format!(
2490                "{} must be greater than 0",
2491                COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES
2492            ));
2493        }
2494        sink_with.insert(
2495            COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES.to_owned(),
2496            write_parquet_max_row_group_bytes.to_string(),
2497        );
2498        source.as_mut().map(|x| {
2499            x.with_properties
2500                .remove(COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES)
2501        });
2502    }
2503
2504    let partition_by = handler_args
2505        .with_options
2506        .get("partition_by")
2507        .map(|v| v.to_owned());
2508
2509    if let Some(partition_by) = &partition_by {
2510        let mut partition_columns = vec![];
2511        for (column, _) in parse_partition_by_exprs(partition_by.clone())? {
2512            table
2513                .columns()
2514                .iter()
2515                .find(|col| col.name().eq_ignore_ascii_case(&column))
2516                .ok_or_else(|| {
2517                    ErrorCode::InvalidInputSyntax(format!(
2518                        "Partition source column does not exist in schema: {}",
2519                        column
2520                    ))
2521                })?;
2522
2523            partition_columns.push(column);
2524        }
2525
2526        ensure_partition_columns_are_prefix_of_primary_key(&partition_columns, &pks).map_err(
2527            |_| {
2528                ErrorCode::InvalidInputSyntax(
2529                    "The partition columns should be the prefix of the primary key".to_owned(),
2530                )
2531            },
2532        )?;
2533
2534        sink_with.insert("partition_by".to_owned(), partition_by.to_owned());
2535
2536        // remove partition_by from source options, otherwise it will be considered as an unknown field.
2537        source
2538            .as_mut()
2539            .map(|x| x.with_properties.remove("partition_by"));
2540    }
2541
2542    let order_key = handler_args
2543        .with_options
2544        .get(ORDER_KEY)
2545        .map(|v| v.to_owned());
2546    if let Some(order_key) = &order_key {
2547        validate_order_key_columns(order_key, table.columns().iter().map(|col| col.name()))
2548            .map_err(|err| ErrorCode::InvalidInputSyntax(err.to_report_string()))?;
2549
2550        sink_with.insert(ORDER_KEY.to_owned(), order_key.to_owned());
2551
2552        source.as_mut().map(|x| x.with_properties.remove(ORDER_KEY));
2553    }
2554
2555    sink_handler_args.with_options =
2556        WithOptions::new(sink_with, Default::default(), connection_ref.clone());
2557    let SinkPlanContext {
2558        sink_plan,
2559        sink_catalog,
2560        ..
2561    } = gen_sink_plan(sink_handler_args, create_sink_stmt, None, true).await?;
2562    let sink_graph = build_graph(sink_plan, Some(GraphJobType::Sink))?;
2563
2564    let mut source_name = table_name.clone();
2565    *source_name.0.last_mut().unwrap() = Ident::from(
2566        (ICEBERG_SOURCE_PREFIX.to_owned() + &source_name.0.last().unwrap().real_value()).as_str(),
2567    );
2568    let create_source_stmt = CreateSourceStatement {
2569        temporary: false,
2570        if_not_exists: false,
2571        columns: vec![],
2572        source_name,
2573        wildcard_idx: Some(0),
2574        constraints: vec![],
2575        with_properties: WithProperties(vec![]),
2576        format_encode: CompatibleFormatEncode::V2(FormatEncodeOptions::none()),
2577        source_watermarks: vec![],
2578        include_column_options: vec![],
2579    };
2580
2581    let mut source_handler_args = handler_args.clone();
2582    let source_with = with_common;
2583    source_handler_args.with_options =
2584        WithOptions::new(source_with, Default::default(), connection_ref);
2585
2586    let overwrite_options = OverwriteOptions::new(&mut source_handler_args);
2587    let format_encode = create_source_stmt.format_encode.into_v2_with_warning();
2588    let (with_properties, refresh_mode) =
2589        bind_connector_props(&source_handler_args, &format_encode, true)?;
2590
2591    // Create iceberg sink table, used for iceberg source column binding. See `bind_columns_from_source_for_non_cdc` for more details.
2592    // TODO: We can derive the columns directly from table definition in the future, so that we don't need to pre-create the table catalog.
2593    let (iceberg_catalog, table_identifier) = {
2594        let sink_param = SinkParam::try_from_sink_catalog(sink_catalog.clone())?;
2595        let iceberg_sink = IcebergSink::try_from(sink_param)?;
2596        iceberg_sink.create_table_if_not_exists().await?;
2597
2598        let iceberg_catalog = iceberg_sink.config.create_catalog().await?;
2599        let table_identifier = iceberg_sink.config.full_table_name()?;
2600        (iceberg_catalog, table_identifier)
2601    };
2602
2603    let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
2604    let (columns_from_resolve_source, source_info) = bind_columns_from_source(
2605        &session,
2606        &format_encode,
2607        Either::Left(&with_properties),
2608        create_source_type,
2609    )
2610    .await?;
2611    let mut col_id_gen = ColumnIdGenerator::new_initial();
2612
2613    let iceberg_source_catalog = bind_create_source_or_table_with_connector(
2614        source_handler_args,
2615        create_source_stmt.source_name,
2616        format_encode,
2617        with_properties,
2618        &create_source_stmt.columns,
2619        create_source_stmt.constraints,
2620        create_source_stmt.wildcard_idx,
2621        create_source_stmt.source_watermarks,
2622        columns_from_resolve_source,
2623        source_info,
2624        create_source_stmt.include_column_options,
2625        &mut col_id_gen,
2626        create_source_type,
2627        overwrite_options.source_rate_limit,
2628        SqlColumnStrategy::FollowChecked,
2629        refresh_mode,
2630    )
2631    .await?;
2632
2633    // before we create the table, ensure the JVM is initialized as we use jdbc catalog right now.
2634    // If JVM isn't initialized successfully, current not atomic ddl will result in a partially created iceberg engine table.
2635    let _ = Jvm::get_or_init()?;
2636
2637    let catalog_writer = session.catalog_writer()?;
2638    let action = match job_type {
2639        TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
2640        _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
2641    };
2642    let res = execute_with_long_running_notification(
2643        catalog_writer.create_iceberg_table(
2644            PbTableJobInfo {
2645                source,
2646                table: Some(table.to_prost()),
2647                fragment_graph: Some(graph),
2648                job_type: job_type as _,
2649            },
2650            PbSinkJobInfo {
2651                sink: Some(sink_catalog.to_proto()),
2652                fragment_graph: Some(sink_graph),
2653            },
2654            iceberg_source_catalog.to_prost(),
2655            if_not_exists,
2656        ),
2657        &session,
2658        "CREATE TABLE",
2659        action,
2660    )
2661    .await;
2662
2663    if res.is_err() {
2664        let _ = iceberg_catalog
2665            .drop_table(&table_identifier)
2666            .await
2667            .inspect_err(|err| {
2668                tracing::error!(
2669                    "failed to drop iceberg table {} after create iceberg engine table failed: {}",
2670                    table_identifier,
2671                    err.as_report()
2672                );
2673            });
2674        res?
2675    }
2676
2677    Ok(())
2678}
2679
2680pub fn check_create_table_with_source(
2681    with_options: &WithOptions,
2682    format_encode: Option<FormatEncodeOptions>,
2683    include_column_options: &IncludeOption,
2684    cdc_table_info: &Option<CdcTableInfo>,
2685) -> Result<Option<FormatEncodeOptions>> {
2686    // skip check for cdc table
2687    if cdc_table_info.is_some() {
2688        return Ok(format_encode);
2689    }
2690    let defined_source = with_options.is_source_connector();
2691
2692    if !include_column_options.is_empty() && !defined_source {
2693        return Err(ErrorCode::InvalidInputSyntax(
2694            "INCLUDE should be used with a connector".to_owned(),
2695        )
2696        .into());
2697    }
2698    if defined_source {
2699        format_encode.as_ref().ok_or_else(|| {
2700            ErrorCode::InvalidInputSyntax("Please specify a source schema using FORMAT".to_owned())
2701        })?;
2702    }
2703    Ok(format_encode)
2704}
2705
2706fn ensure_partition_columns_are_prefix_of_primary_key(
2707    partition_columns: &[String],
2708    primary_key_columns: &[String],
2709) -> std::result::Result<(), String> {
2710    if partition_columns.len() > primary_key_columns.len() {
2711        return Err("Partition columns cannot be longer than primary key columns.".to_owned());
2712    }
2713
2714    for (i, partition_col) in partition_columns.iter().enumerate() {
2715        if primary_key_columns.get(i) != Some(partition_col) {
2716            return Err(format!(
2717                "Partition column '{}' is not a prefix of the primary key.",
2718                partition_col
2719            ));
2720        }
2721    }
2722
2723    Ok(())
2724}
2725
2726#[allow(clippy::too_many_arguments)]
2727pub async fn generate_stream_graph_for_replace_table(
2728    _session: &Arc<SessionImpl>,
2729    table_name: ObjectName,
2730    original_catalog: &Arc<TableCatalog>,
2731    handler_args: HandlerArgs,
2732    statement: Statement,
2733    col_id_gen: ColumnIdGenerator,
2734    sql_column_strategy: SqlColumnStrategy,
2735) -> Result<(
2736    StreamFragmentGraph,
2737    TableCatalog,
2738    Option<SourceCatalog>,
2739    TableJobType,
2740)> {
2741    let Statement::CreateTable {
2742        columns,
2743        constraints,
2744        source_watermarks,
2745        append_only,
2746        on_conflict,
2747        with_version_columns,
2748        wildcard_idx,
2749        cdc_table_info,
2750        format_encode,
2751        include_column_options,
2752        engine,
2753        with_options,
2754        ..
2755    } = statement
2756    else {
2757        panic!("unexpected statement type: {:?}", statement);
2758    };
2759
2760    let format_encode = format_encode
2761        .clone()
2762        .map(|format_encode| format_encode.into_v2_with_warning());
2763
2764    let engine = match engine {
2765        risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
2766        risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
2767    };
2768
2769    let is_drop_connector =
2770        original_catalog.associated_source_id().is_some() && format_encode.is_none();
2771    if is_drop_connector {
2772        debug_assert!(
2773            source_watermarks.is_empty()
2774                && include_column_options.is_empty()
2775                && with_options
2776                    .iter()
2777                    .all(|opt| opt.name.real_value().to_lowercase() != "connector")
2778        );
2779    }
2780
2781    let props = CreateTableProps {
2782        definition: handler_args.normalized_sql.clone(),
2783        append_only,
2784        on_conflict: on_conflict.into(),
2785        with_version_columns: with_version_columns
2786            .iter()
2787            .map(|col| col.real_value())
2788            .collect(),
2789        webhook_info: original_catalog.webhook_info.clone(),
2790        engine,
2791    };
2792
2793    let ((plan, mut source, mut table), job_type) = match (format_encode, cdc_table_info.as_ref()) {
2794        (Some(format_encode), None) => (
2795            gen_create_table_plan_with_source(
2796                handler_args,
2797                ExplainOptions::default(),
2798                table_name,
2799                columns,
2800                wildcard_idx,
2801                constraints,
2802                format_encode,
2803                source_watermarks,
2804                col_id_gen,
2805                include_column_options,
2806                props,
2807                sql_column_strategy,
2808            )
2809            .await?,
2810            TableJobType::General,
2811        ),
2812        (None, None) => {
2813            let context = OptimizerContext::from_handler_args(handler_args);
2814            let (plan, table) = gen_create_table_plan(
2815                context,
2816                table_name,
2817                columns,
2818                constraints,
2819                col_id_gen,
2820                source_watermarks,
2821                props,
2822                true,
2823            )?;
2824            ((plan, None, table), TableJobType::General)
2825        }
2826        (None, Some(cdc_table)) => {
2827            sanity_check_for_table_on_cdc_source(
2828                append_only,
2829                &columns,
2830                &wildcard_idx,
2831                &constraints,
2832                &source_watermarks,
2833            )?;
2834
2835            let session = &handler_args.session;
2836            let (source, resolved_table_name) =
2837                get_source_and_resolved_table_name(session, cdc_table.clone(), table_name.clone())?;
2838
2839            let (cdc_with_options, normalized_external_table_name) =
2840                derive_with_options_for_cdc_table(
2841                    &source.with_properties,
2842                    cdc_table.external_table_name.clone(),
2843                )?;
2844
2845            let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
2846
2847            // CDC-table branch only: see the comment at the symmetric call site in
2848            // `handle_create_table_plan`. Plain (non-CDC) tables don't hit this check.
2849            reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
2850
2851            let context: OptimizerContextRef =
2852                OptimizerContext::new(handler_args, ExplainOptions::default()).into();
2853            let (plan, table) = gen_create_table_plan_for_cdc_table(
2854                context,
2855                source,
2856                normalized_external_table_name,
2857                columns,
2858                source_watermarks,
2859                column_catalogs,
2860                pk_names,
2861                cdc_with_options,
2862                col_id_gen,
2863                on_conflict,
2864                with_version_columns
2865                    .iter()
2866                    .map(|col| col.real_value())
2867                    .collect(),
2868                include_column_options,
2869                table_name,
2870                resolved_table_name,
2871                original_catalog.database_id,
2872                original_catalog.schema_id,
2873                original_catalog.id(),
2874                engine,
2875            )?;
2876
2877            ((plan, None, table), TableJobType::SharedCdcSource)
2878        }
2879        (Some(_), Some(_)) => {
2880            return Err(ErrorCode::NotSupported(
2881                "Data format and encoding format doesn't apply to table created from a CDC source"
2882                    .into(),
2883                "Remove the FORMAT and ENCODE specification".into(),
2884            )
2885            .into());
2886        }
2887    };
2888
2889    if table.pk_column_ids() != original_catalog.pk_column_ids() {
2890        Err(ErrorCode::InvalidInputSyntax(
2891            "alter primary key of table is not supported".to_owned(),
2892        ))?
2893    }
2894
2895    let graph = build_graph(plan, Some(GraphJobType::Table))?;
2896
2897    // Fill the original table ID.
2898    table.id = original_catalog.id();
2899    if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2900        table.associated_source_id = Some(source_id);
2901
2902        let source = source.as_mut().unwrap();
2903        source.id = source_id;
2904        source.associated_table_id = Some(table.id());
2905    }
2906
2907    Ok((graph, table, source, job_type))
2908}
2909
2910fn get_source_and_resolved_table_name(
2911    session: &Arc<SessionImpl>,
2912    cdc_table: CdcTableInfo,
2913    table_name: ObjectName,
2914) -> Result<(Arc<SourceCatalog>, String)> {
2915    let db_name = &session.database();
2916    let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2917
2918    let (source_schema, source_name) =
2919        Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2920
2921    let source = {
2922        let catalog_reader = session.env().catalog_reader().read_guard();
2923        let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2924        let (source, _) = catalog_reader.get_source_by_name(
2925            db_name,
2926            SchemaPath::Name(schema_name.as_str()),
2927            source_name.as_str(),
2928        )?;
2929        source.clone()
2930    };
2931
2932    Ok((source, resolved_table_name))
2933}
2934
2935// validate the webhook_info and also bind the webhook_info to protobuf
2936fn bind_webhook_info(
2937    session: &Arc<SessionImpl>,
2938    column_defs: &[ColumnDef],
2939    webhook_info: WebhookSourceInfo,
2940) -> Result<PbWebhookSourceInfo> {
2941    let WebhookSourceInfo {
2942        secret_ref,
2943        signature_expr,
2944        wait_for_persistence,
2945        is_batched,
2946    } = webhook_info;
2947
2948    for column in column_defs {
2949        for option_def in &column.options {
2950            match option_def.option {
2951                ColumnOption::Null => {}
2952                ColumnOption::GeneratedColumns(_) => {
2953                    return Err(ErrorCode::InvalidInputSyntax(
2954                        "generated columns are not supported for webhook tables".to_owned(),
2955                    )
2956                    .into());
2957                }
2958                ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2959                    return Err(ErrorCode::InvalidInputSyntax(
2960                        "default values are not supported for webhook tables".to_owned(),
2961                    )
2962                    .into());
2963                }
2964                ColumnOption::NotNull
2965                | ColumnOption::Unique { .. }
2966                | ColumnOption::ForeignKey { .. }
2967                | ColumnOption::Check(_)
2968                | ColumnOption::DialectSpecific(_) => {
2969                    return Err(ErrorCode::InvalidInputSyntax(
2970                        "only NULL column option is supported for webhook tables".to_owned(),
2971                    )
2972                    .into());
2973                }
2974            }
2975        }
2976    }
2977
2978    // validate secret_ref
2979    let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2980        let db_name = &session.database();
2981        let (schema_name, secret_name) =
2982            Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2983        let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2984        (
2985            Some(PbSecretRef {
2986                secret_id: secret_catalog.id,
2987                ref_as: match secret_ref.ref_as {
2988                    SecretRefAsType::Text => PbRefAsType::Text,
2989                    SecretRefAsType::File => PbRefAsType::File,
2990                }
2991                .into(),
2992            }),
2993            Some(secret_name),
2994        )
2995    } else {
2996        (None, None)
2997    };
2998
2999    let signature_expr = if let Some(signature_expr) = signature_expr {
3000        let payload_name = if column_defs.len() == 1
3001            && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
3002        {
3003            column_defs[0].name.real_value()
3004        } else {
3005            WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
3006        };
3007        let secure_compare_context = SecureCompareContext {
3008            payload_name,
3009            secret_name,
3010        };
3011        let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
3012        let expr = binder.bind_expr(&signature_expr)?;
3013
3014        // validate expr, ensuring it is SECURE_COMPARE()
3015        if expr.as_function_call().is_none()
3016            || expr.as_function_call().unwrap().func_type()
3017                != crate::optimizer::plan_node::generic::ExprType::SecureCompare
3018        {
3019            return Err(ErrorCode::InvalidInputSyntax(
3020                "The signature verification function must be SECURE_COMPARE()".to_owned(),
3021            )
3022            .into());
3023        }
3024
3025        Some(expr.to_expr_proto())
3026    } else {
3027        session.notice_to_user(
3028            "VALIDATE clause is strongly recommended for safety or production usages",
3029        );
3030        None
3031    };
3032
3033    let pb_webhook_info = PbWebhookSourceInfo {
3034        secret_ref: pb_secret_ref,
3035        signature_expr,
3036        wait_for_persistence,
3037        is_batched,
3038    };
3039
3040    Ok(pb_webhook_info)
3041}
3042
3043#[cfg(test)]
3044mod tests {
3045    use risingwave_common::catalog::{
3046        DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
3047    };
3048    use risingwave_common::types::{DataType, StructType};
3049
3050    use super::*;
3051    use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
3052
3053    fn test_schema_table_name() -> SchemaTableName {
3054        SchemaTableName {
3055            schema_name: "public".to_owned(),
3056            table_name: "orders".to_owned(),
3057        }
3058    }
3059
3060    fn pk_names() -> Vec<String> {
3061        vec!["plan_id".to_owned(), "site_id".to_owned()]
3062    }
3063
3064    #[test]
3065    fn test_debezium_filter_rejects_literal_excluded_pk() {
3066        let err = reject_pk_filtered_by_debezium_column_filter_inner(
3067            &pk_names(),
3068            &test_schema_table_name(),
3069            Some("public.orders.site_id"),
3070            None,
3071        )
3072        .unwrap_err();
3073
3074        assert!(err.to_report_string().contains("site_id"));
3075        assert!(
3076            err.to_report_string()
3077                .contains("debezium.column.exclude.list")
3078        );
3079    }
3080
3081    #[test]
3082    fn test_debezium_filter_rejects_include_list_missing_pk() {
3083        let err = reject_pk_filtered_by_debezium_column_filter_inner(
3084            &pk_names(),
3085            &test_schema_table_name(),
3086            None,
3087            Some("public.orders.plan_id,public.orders.payload"),
3088        )
3089        .unwrap_err();
3090
3091        assert!(err.to_report_string().contains("site_id"));
3092        assert!(
3093            err.to_report_string()
3094                .contains("debezium.column.include.list")
3095        );
3096    }
3097
3098    #[test]
3099    fn test_debezium_filter_accepts_include_list_covering_all_pks() {
3100        reject_pk_filtered_by_debezium_column_filter_inner(
3101            &pk_names(),
3102            &test_schema_table_name(),
3103            None,
3104            Some("public.orders.plan_id,public.orders.site_id,public.orders.payload"),
3105        )
3106        .unwrap();
3107    }
3108
3109    #[test]
3110    fn test_debezium_filter_matches_regex_patterns() {
3111        reject_pk_filtered_by_debezium_column_filter_inner(
3112            &pk_names(),
3113            &test_schema_table_name(),
3114            None,
3115            Some(r"public[.]orders[.](plan_id|site_id),public[.]orders[.]payload"),
3116        )
3117        .unwrap();
3118
3119        let err = reject_pk_filtered_by_debezium_column_filter_inner(
3120            &pk_names(),
3121            &test_schema_table_name(),
3122            Some(r".*[.]orders[.]site_id"),
3123            None,
3124        )
3125        .unwrap_err();
3126
3127        assert!(err.to_report_string().contains("site_id"));
3128    }
3129
3130    #[test]
3131    fn test_debezium_filter_matches_patterns_case_insensitively() {
3132        let err = reject_pk_filtered_by_debezium_column_filter_inner(
3133            &pk_names(),
3134            &test_schema_table_name(),
3135            Some("PUBLIC.ORDERS.SITE_ID"),
3136            None,
3137        )
3138        .unwrap_err();
3139
3140        assert!(err.to_report_string().contains("site_id"));
3141
3142        reject_pk_filtered_by_debezium_column_filter_inner(
3143            &pk_names(),
3144            &test_schema_table_name(),
3145            None,
3146            Some("Public.Orders.Plan_ID,Public.Orders.Site_ID"),
3147        )
3148        .unwrap();
3149    }
3150
3151    #[tokio::test]
3152    async fn test_create_table_handler() {
3153        let sql =
3154            "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
3155        let frontend = LocalFrontend::new(Default::default()).await;
3156        frontend.run_sql(sql).await.unwrap();
3157
3158        let session = frontend.session_ref();
3159        let catalog_reader = session.env().catalog_reader().read_guard();
3160        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
3161
3162        // Check table exists.
3163        let (table, _) = catalog_reader
3164            .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
3165            .unwrap();
3166        assert_eq!(table.name(), "t");
3167
3168        let columns = table
3169            .columns
3170            .iter()
3171            .map(|col| (col.name(), col.data_type().clone()))
3172            .collect::<HashMap<&str, DataType>>();
3173
3174        let expected_columns = maplit::hashmap! {
3175            ROW_ID_COLUMN_NAME => DataType::Serial,
3176            "v1" => DataType::Int16,
3177            "v2" => StructType::new(
3178                vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
3179            )
3180            .with_ids([3, 4, 5].map(ColumnId::new))
3181            .into(),
3182            RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
3183        };
3184
3185        assert_eq!(columns, expected_columns, "{columns:#?}");
3186    }
3187
3188    #[tokio::test]
3189    async fn test_create_webhook_table_with_arbitrary_columns() {
3190        let frontend = LocalFrontend::new(Default::default()).await;
3191        frontend
3192            .run_sql("create schema ingest_schema;")
3193            .await
3194            .unwrap();
3195        frontend
3196            .run_sql(
3197                r#"
3198                create table ingest_schema.orders (
3199                    id int,
3200                    customer_name varchar,
3201                    amount double precision,
3202                    primary key (id)
3203                ) with (
3204                    connector = 'webhook'
3205                ) validate as secure_compare(
3206                    headers->>'x-rw-signature',
3207                    'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
3208                );
3209                "#,
3210            )
3211            .await
3212            .unwrap();
3213
3214        let session = frontend.session_ref();
3215        let catalog_reader = session.env().catalog_reader().read_guard();
3216        let (table, _) = catalog_reader
3217            .get_created_table_by_name(
3218                DEFAULT_DATABASE_NAME,
3219                SchemaPath::Name("ingest_schema"),
3220                "orders",
3221            )
3222            .unwrap();
3223
3224        assert!(table.webhook_info.is_some());
3225        assert_eq!(
3226            table
3227                .columns
3228                .iter()
3229                .filter(|column| column.can_dml())
3230                .count(),
3231            3
3232        );
3233    }
3234
3235    #[tokio::test]
3236    async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
3237        let frontend = LocalFrontend::new(Default::default()).await;
3238        frontend
3239            .run_sql(
3240                r#"
3241                create table webhook_single_column (
3242                    body jsonb
3243                ) with (
3244                    connector = 'webhook'
3245                ) validate as secure_compare(
3246                    headers->>'x-rw-signature',
3247                    'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
3248                );
3249                "#,
3250            )
3251            .await
3252            .unwrap();
3253    }
3254
3255    #[tokio::test]
3256    async fn test_create_webhook_table_with_generated_columns() {
3257        let frontend = LocalFrontend::new(Default::default()).await;
3258        let err = frontend
3259            .run_sql(
3260                r#"
3261                create table webhook_generated_columns (
3262                    id int,
3263                    amount double precision,
3264                    amount_with_fee double precision as amount + 1.0
3265                ) with (
3266                    connector = 'webhook'
3267                );
3268                "#,
3269            )
3270            .await
3271            .unwrap_err();
3272
3273        assert!(
3274            err.to_string()
3275                .contains("generated columns are not supported for webhook tables"),
3276            "{err:?}"
3277        );
3278    }
3279
3280    #[tokio::test]
3281    async fn test_create_webhook_table_with_default_value() {
3282        let frontend = LocalFrontend::new(Default::default()).await;
3283        let err = frontend
3284            .run_sql(
3285                r#"
3286                create table webhook_default_value (
3287                    id int default 42,
3288                    amount double precision
3289                ) with (
3290                    connector = 'webhook'
3291                );
3292                "#,
3293            )
3294            .await
3295            .unwrap_err();
3296
3297        assert!(
3298            err.to_string()
3299                .contains("default values are not supported for webhook tables"),
3300            "{err:?}"
3301        );
3302    }
3303
3304    #[tokio::test]
3305    async fn test_create_webhook_table_with_not_null_option() {
3306        let frontend = LocalFrontend::new(Default::default()).await;
3307        let err = frontend
3308            .run_sql(
3309                r#"
3310                create table webhook_not_null (
3311                    id int not null,
3312                    amount double precision
3313                ) with (
3314                    connector = 'webhook'
3315                );
3316                "#,
3317            )
3318            .await
3319            .unwrap_err();
3320
3321        assert!(
3322            err.to_string()
3323                .contains("only NULL column option is supported for webhook tables"),
3324            "{err:?}"
3325        );
3326    }
3327
3328    #[test]
3329    fn test_parse_postgres_cdc_external_table_name() {
3330        for (input, expected) in [
3331            ("public.Note", ("public", "Note")),
3332            ("public.\"Note\"", ("public", "Note")),
3333            (
3334                "\"Mixed.Schema\".\"Note.Table\"",
3335                ("Mixed.Schema", "Note.Table"),
3336            ),
3337            ("public.\"Note\"\"Archive\"", ("public", "Note\"Archive")),
3338        ] {
3339            assert_eq!(
3340                parse_postgres_cdc_external_table_name(input).unwrap(),
3341                (expected.0.to_owned(), expected.1.to_owned()),
3342                "input: {input}"
3343            );
3344        }
3345
3346        for input in [
3347            "Note",
3348            "public.",
3349            ".Note",
3350            "public.\"Note",
3351            "public.\"Note\"Archive",
3352            "public.Note.Archive",
3353        ] {
3354            assert!(
3355                parse_postgres_cdc_external_table_name(input).is_err(),
3356                "input should be rejected: {input}"
3357            );
3358        }
3359    }
3360
3361    #[test]
3362    fn test_bind_primary_key() {
3363        // Note: Column ID 0 is reserved for row ID column.
3364
3365        for (sql, expected) in [
3366            ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
3367            ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
3368            ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
3369            (
3370                "create table t (v1 int primary key, v2 int primary key)",
3371                Err("multiple primary keys are not allowed"),
3372            ),
3373            (
3374                "create table t (v1 int primary key primary key, v2 int)",
3375                Err("multiple primary keys are not allowed"),
3376            ),
3377            (
3378                "create table t (v1 int, v2 int, primary key (v1))",
3379                Ok(&[1]),
3380            ),
3381            (
3382                "create table t (v1 int, primary key (v2), v2 int)",
3383                Ok(&[2]),
3384            ),
3385            (
3386                "create table t (primary key (v2, v1), v1 int, v2 int)",
3387                Ok(&[2, 1]),
3388            ),
3389            (
3390                "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
3391                Err("multiple primary keys are not allowed"),
3392            ),
3393            (
3394                "create table t (v1 int primary key, primary key (v1), v2 int)",
3395                Err("multiple primary keys are not allowed"),
3396            ),
3397            (
3398                "create table t (v1 int, primary key (V3), v2 int)",
3399                Err("column \"v3\" named in key does not exist"),
3400            ),
3401        ] {
3402            let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
3403            let risingwave_sqlparser::ast::Statement::CreateTable {
3404                columns: column_defs,
3405                constraints,
3406                ..
3407            } = ast.remove(0)
3408            else {
3409                panic!("test case should be create table")
3410            };
3411            let actual: Result<_> = (|| {
3412                let mut columns = bind_sql_columns(&column_defs, false)?;
3413                let mut col_id_gen = ColumnIdGenerator::new_initial();
3414                for c in &mut columns {
3415                    col_id_gen.generate(c)?;
3416                }
3417
3418                let pk_names =
3419                    bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
3420                let (_, pk_column_ids, _) =
3421                    bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
3422                Ok(pk_column_ids)
3423            })();
3424            match (expected, actual) {
3425                (Ok(expected), Ok(actual)) => assert_eq!(
3426                    expected.iter().copied().map(ColumnId::new).collect_vec(),
3427                    actual,
3428                    "sql: {sql}"
3429                ),
3430                (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
3431                (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
3432                (Err(expected), Err(actual)) => assert!(
3433                    actual.to_string().contains(expected),
3434                    "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
3435                ),
3436            }
3437        }
3438    }
3439
3440    #[tokio::test]
3441    async fn test_duplicate_props_options() {
3442        let proto_file = create_proto_file(PROTO_FILE_DATA);
3443        let sql = format!(
3444            r#"CREATE TABLE t
3445    WITH (
3446        connector = 'kinesis',
3447        aws.region='user_test_topic',
3448        endpoint='172.10.1.1:9090,172.10.1.2:9090',
3449        aws.credentials.access_key_id = 'your_access_key_1',
3450        aws.credentials.secret_access_key = 'your_secret_key_1'
3451    )
3452    FORMAT PLAIN ENCODE PROTOBUF (
3453        message = '.test.TestRecord',
3454        aws.credentials.access_key_id = 'your_access_key_2',
3455        aws.credentials.secret_access_key = 'your_secret_key_2',
3456        schema.location = 'file://{}',
3457    )"#,
3458            proto_file.path().to_str().unwrap()
3459        );
3460        let frontend = LocalFrontend::new(Default::default()).await;
3461        frontend.run_sql(sql).await.unwrap();
3462
3463        let session = frontend.session_ref();
3464        let catalog_reader = session.env().catalog_reader().read_guard();
3465        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
3466
3467        // Check source exists.
3468        let (source, _) = catalog_reader
3469            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
3470            .unwrap();
3471        assert_eq!(source.name, "t");
3472
3473        // AwsAuth params exist in options.
3474        assert_eq!(
3475            source
3476                .info
3477                .format_encode_options
3478                .get("aws.credentials.access_key_id")
3479                .unwrap(),
3480            "your_access_key_2"
3481        );
3482        assert_eq!(
3483            source
3484                .info
3485                .format_encode_options
3486                .get("aws.credentials.secret_access_key")
3487                .unwrap(),
3488            "your_secret_key_2"
3489        );
3490
3491        // AwsAuth params exist in props.
3492        assert_eq!(
3493            source
3494                .with_properties
3495                .get("aws.credentials.access_key_id")
3496                .unwrap(),
3497            "your_access_key_1"
3498        );
3499        assert_eq!(
3500            source
3501                .with_properties
3502                .get("aws.credentials.secret_access_key")
3503                .unwrap(),
3504            "your_secret_key_1"
3505        );
3506
3507        // Options are not merged into props.
3508        assert!(!source.with_properties.contains_key("schema.location"));
3509    }
3510}