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