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