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