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