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;
20use clap::ValueEnum;
21use either::Either;
22use fixedbitset::FixedBitSet;
23use iceberg::spec::FormatVersion;
24use itertools::Itertools;
25use percent_encoding::percent_decode_str;
26use pgwire::pg_response::{PgResponse, StatementType};
27use prost::Message as _;
28use risingwave_common::acl::AclMode;
29use risingwave_common::catalog::{
30    CdcKeyComparison, CdcTableDesc, ColumnCatalog, ColumnDesc, ConflictBehavior,
31    DEFAULT_SCHEMA_NAME, Engine, ICEBERG_SINK_PREFIX, ICEBERG_SOURCE_PREFIX,
32    RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME, TableId,
33};
34use risingwave_common::config::MetaBackend;
35use risingwave_common::global_jvm::Jvm;
36use risingwave_common::session_config::sink_decouple::SinkDecouple;
37use risingwave_common::types::DataType;
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::ExternalCdcTableType;
42use risingwave_connector::source::cdc::{
43    build_cdc_table_id, normalize_simple_postgres_quoted_table_name,
44};
45use risingwave_connector::{AUTO_SCHEMA_CHANGE_KEY, WithOptionsSecResolved, WithPropertiesExt};
46use risingwave_pb::catalog::connection::Info as ConnectionInfo;
47use risingwave_pb::catalog::connection_params::ConnectionType;
48use risingwave_pb::catalog::{PbSource, PbWebhookSourceInfo, WatermarkDesc};
49use risingwave_pb::ddl_service::{PbTableJobType, TableJobType};
50use risingwave_pb::plan_common::column_desc::GeneratedOrDefaultColumn;
51use risingwave_pb::plan_common::{
52    AdditionalColumn, ColumnDescVersion, DefaultColumnDesc, GeneratedColumnDesc,
53};
54use risingwave_pb::secret::PbSecretRef;
55use risingwave_pb::secret::secret_ref::PbRefAsType;
56use risingwave_pb::stream_plan::StreamFragmentGraph;
57use risingwave_sqlparser::ast::{
58    CdcTableInfo, ColumnDef, ColumnOption, CompatibleFormatEncode, ConnectionRefValue, CreateSink,
59    CreateSinkStatement, CreateSourceStatement, DataType as AstDataType, ExplainOptions, Format,
60    FormatEncodeOptions, Ident, ObjectName, OnConflict, SecretRefAsType, SourceWatermark,
61    Statement, TableConstraint, WebhookSourceInfo, WithProperties,
62};
63use risingwave_sqlparser::parser::IncludeOption;
64use thiserror_ext::AsReport;
65
66use super::RwPgResponse;
67use super::create_source::{CreateSourceType, SqlColumnStrategy, bind_columns_from_source};
68use crate::binder::{Clause, SecureCompareContext, WEBHOOK_PAYLOAD_FIELD_NAME, bind_data_type};
69use crate::catalog::root_catalog::SchemaPath;
70use crate::catalog::source_catalog::SourceCatalog;
71use crate::catalog::table_catalog::TableVersion;
72use crate::catalog::{ColumnId, DatabaseId, SchemaId, SourceId, check_column_name_not_reserved};
73use crate::error::{ErrorCode, Result, RwError, bail_bind_error};
74use crate::expr::{Expr, ExprImpl, ExprRewriter};
75use crate::handler::HandlerArgs;
76use crate::handler::cdc::{
77    bind_cdc_pk_comparisons_externally, bind_cdc_table_schema, bind_cdc_table_schema_externally,
78    derive_with_options_for_cdc_table, not_null_check_for_cdc_table,
79    reject_pk_filtered_by_debezium_column_filter, sanity_check_for_table_on_cdc_source,
80};
81use crate::handler::create_source::{
82    bind_connector_props, bind_create_source_or_table_with_connector, bind_source_watermark,
83    handle_addition_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    // Check the CREATE privilege before `bind_columns_from_source`, which may fetch from
507    // user-supplied URLs (schema registry, schema file).
508    session.get_database_and_schema_id_for_create(schema_name.clone())?;
509
510    // TODO: omit this step if `sql_column_strategy` is `Follow`.
511    let (columns_from_resolve_source, source_info) = bind_columns_from_source(
512        session,
513        &format_encode,
514        Either::Left(&with_properties),
515        CreateSourceType::Table,
516    )
517    .await?;
518
519    let overwrite_options = OverwriteOptions::new(&mut handler_args);
520    let rate_limit = overwrite_options.source_rate_limit;
521    let source = bind_create_source_or_table_with_connector(
522        handler_args.clone(),
523        table_name,
524        format_encode,
525        with_properties,
526        &column_defs,
527        constraints,
528        wildcard_idx,
529        source_watermarks,
530        columns_from_resolve_source,
531        source_info,
532        include_column_options,
533        &mut col_id_gen,
534        CreateSourceType::Table,
535        rate_limit,
536        sql_column_strategy,
537        refresh_mode,
538    )
539    .await?;
540
541    let context = OptimizerContext::new(handler_args, explain_options);
542
543    let (plan, table) = gen_table_plan_with_source(
544        context.into(),
545        schema_name,
546        source.clone(),
547        col_id_gen.into_version(),
548        props,
549    )?;
550
551    Ok((plan, Some(source), table))
552}
553
554/// `gen_create_table_plan` generates the plan for creating a table without an external stream
555/// source.
556#[allow(clippy::too_many_arguments)]
557pub(crate) fn gen_create_table_plan(
558    context: OptimizerContext,
559    table_name: ObjectName,
560    column_defs: Vec<ColumnDef>,
561    constraints: Vec<TableConstraint>,
562    mut col_id_gen: ColumnIdGenerator,
563    source_watermarks: Vec<SourceWatermark>,
564    props: CreateTableProps,
565    is_for_replace_plan: bool,
566) -> Result<(PlanRef, TableCatalog)> {
567    let mut columns = bind_sql_columns(&column_defs, is_for_replace_plan)?;
568    for c in &mut columns {
569        col_id_gen.generate(c)?;
570    }
571
572    let (_, secret_refs, connection_refs) = context.with_options().clone().into_parts();
573    if !secret_refs.is_empty() || !connection_refs.is_empty() {
574        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());
575    }
576
577    gen_create_table_plan_without_source(
578        context,
579        table_name,
580        columns,
581        column_defs,
582        constraints,
583        source_watermarks,
584        col_id_gen.into_version(),
585        props,
586    )
587}
588
589#[allow(clippy::too_many_arguments)]
590pub(crate) fn gen_create_table_plan_without_source(
591    context: OptimizerContext,
592    table_name: ObjectName,
593    columns: Vec<ColumnCatalog>,
594    column_defs: Vec<ColumnDef>,
595    constraints: Vec<TableConstraint>,
596    source_watermarks: Vec<SourceWatermark>,
597    version: TableVersion,
598    props: CreateTableProps,
599) -> Result<(PlanRef, TableCatalog)> {
600    // XXX: Why not bind outside?
601    let pk_names = bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
602    let (mut columns, pk_column_ids, row_id_index) =
603        bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
604
605    let watermark_descs = bind_source_watermark(
606        context.session_ctx(),
607        table_name.real_value(),
608        source_watermarks,
609        &columns,
610    )?;
611
612    bind_sql_column_constraints(
613        context.session_ctx(),
614        table_name.real_value(),
615        &mut columns,
616        &column_defs,
617        &pk_column_ids,
618    )?;
619    let session = context.session_ctx().clone();
620
621    let db_name = &session.database();
622    let (schema_name, table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
623
624    let info = CreateTableInfo {
625        columns,
626        pk_column_ids,
627        row_id_index,
628        watermark_descs,
629        source_catalog: None,
630        version,
631    };
632
633    gen_table_plan_inner(context.into(), schema_name, table_name, info, props)
634}
635
636fn gen_table_plan_with_source(
637    context: OptimizerContextRef,
638    schema_name: Option<String>,
639    source_catalog: SourceCatalog,
640    version: TableVersion,
641    props: CreateTableProps,
642) -> Result<(PlanRef, TableCatalog)> {
643    let table_name = source_catalog.name.clone();
644
645    let info = CreateTableInfo {
646        columns: source_catalog.columns.clone(),
647        pk_column_ids: source_catalog.pk_col_ids.clone(),
648        row_id_index: source_catalog.row_id_index,
649        watermark_descs: source_catalog.watermark_descs.clone(),
650        source_catalog: Some(source_catalog),
651        version,
652    };
653
654    gen_table_plan_inner(context, schema_name, table_name, info, props)
655}
656
657/// On-conflict behavior either from user input or existing table catalog.
658#[derive(Clone, Copy)]
659pub enum EitherOnConflict {
660    Ast(Option<OnConflict>),
661    Resolved(ConflictBehavior),
662}
663
664impl From<Option<OnConflict>> for EitherOnConflict {
665    fn from(v: Option<OnConflict>) -> Self {
666        Self::Ast(v)
667    }
668}
669
670impl From<ConflictBehavior> for EitherOnConflict {
671    fn from(v: ConflictBehavior) -> Self {
672        Self::Resolved(v)
673    }
674}
675
676impl EitherOnConflict {
677    /// Resolves the conflict behavior based on the given information.
678    pub fn to_behavior(self, append_only: bool, row_id_as_pk: bool) -> Result<ConflictBehavior> {
679        let conflict_behavior = match self {
680            EitherOnConflict::Ast(on_conflict) => {
681                if append_only {
682                    if row_id_as_pk {
683                        // Primary key will be generated, no conflict check needed.
684                        ConflictBehavior::NoCheck
685                    } else {
686                        // User defined PK on append-only table, enforce `DO NOTHING`.
687                        if let Some(on_conflict) = on_conflict
688                            && on_conflict != OnConflict::Nothing
689                        {
690                            return Err(ErrorCode::InvalidInputSyntax(
691                                "When PRIMARY KEY constraint applied to an APPEND ONLY table, \
692                                     the ON CONFLICT behavior must be DO NOTHING."
693                                    .to_owned(),
694                            )
695                            .into());
696                        }
697                        ConflictBehavior::IgnoreConflict
698                    }
699                } else {
700                    // Default to `UPDATE FULL` for non-append-only tables.
701                    match on_conflict.unwrap_or(OnConflict::UpdateFull) {
702                        OnConflict::UpdateFull => ConflictBehavior::Overwrite,
703                        OnConflict::Nothing => ConflictBehavior::IgnoreConflict,
704                        OnConflict::UpdateIfNotNull => ConflictBehavior::DoUpdateIfNotNull,
705                    }
706                }
707            }
708            EitherOnConflict::Resolved(b) => b,
709        };
710
711        Ok(conflict_behavior)
712    }
713}
714
715/// Arguments of the functions that generate a table plan, part 1.
716///
717/// Compared to [`CreateTableProps`], this struct contains fields that need some work of binding
718/// or resolving based on the user input.
719pub struct CreateTableInfo {
720    pub columns: Vec<ColumnCatalog>,
721    pub pk_column_ids: Vec<ColumnId>,
722    pub row_id_index: Option<usize>,
723    pub watermark_descs: Vec<WatermarkDesc>,
724    pub source_catalog: Option<SourceCatalog>,
725    pub version: TableVersion,
726}
727
728/// Arguments of the functions that generate a table plan, part 2.
729///
730/// Compared to [`CreateTableInfo`], this struct contains fields that can be (relatively) simply
731/// obtained from the input or the context.
732pub struct CreateTableProps {
733    pub definition: String,
734    pub append_only: bool,
735    pub on_conflict: EitherOnConflict,
736    pub with_version_columns: Vec<String>,
737    pub webhook_info: Option<PbWebhookSourceInfo>,
738    pub engine: Engine,
739}
740
741#[allow(clippy::too_many_arguments)]
742fn gen_table_plan_inner(
743    context: OptimizerContextRef,
744    schema_name: Option<String>,
745    table_name: String,
746    info: CreateTableInfo,
747    props: CreateTableProps,
748) -> Result<(PlanRef, TableCatalog)> {
749    let CreateTableInfo {
750        ref columns,
751        row_id_index,
752        ref watermark_descs,
753        ref source_catalog,
754        ..
755    } = info;
756    let CreateTableProps { append_only, .. } = props;
757
758    let (database_id, schema_id) = context
759        .session_ctx()
760        .get_database_and_schema_id_for_create(schema_name)?;
761
762    let session = context.session_ctx().clone();
763    let retention_seconds = context.with_options().retention_seconds();
764
765    let source_node: LogicalPlanRef = LogicalSource::new(
766        source_catalog.clone().map(Rc::new),
767        columns.clone(),
768        row_id_index,
769        SourceNodeKind::CreateTable,
770        context.clone(),
771        None,
772    )?
773    .into();
774
775    let required_cols = FixedBitSet::with_capacity(columns.len());
776    let plan_root = PlanRoot::new_with_logical_plan(
777        source_node,
778        RequiredDist::Any,
779        Order::any(),
780        required_cols,
781        vec![],
782    );
783
784    let has_non_ttl_watermark = watermark_descs.iter().any(|d| !d.with_ttl);
785
786    if !append_only && has_non_ttl_watermark {
787        return Err(ErrorCode::NotSupported(
788            "Defining watermarks on table requires the table to be append only.".to_owned(),
789            "Use the key words `APPEND ONLY`".to_owned(),
790        )
791        .into());
792    }
793
794    if !append_only && retention_seconds.is_some() {
795        if session
796            .config()
797            .unsafe_enable_storage_retention_for_non_append_only_tables()
798        {
799            tracing::warn!(
800                "Storage retention is enabled for non-append-only table {}. This may lead to stream inconsistency.",
801                table_name
802            );
803            const NOTICE: &str = "Storage retention is enabled for non-append-only table. \
804                                  This may lead to stream inconsistency and unrecoverable \
805                                  node failure if there is any row INSERT/UPDATE/DELETE operation \
806                                  corresponding to the TTLed primary keys";
807            session.notice_to_user(NOTICE);
808        } else {
809            return Err(ErrorCode::NotSupported(
810                "Defining retention seconds on table requires the table to be append only."
811                    .to_owned(),
812                "Use the key words `APPEND ONLY`".to_owned(),
813            )
814            .into());
815        }
816    }
817
818    let materialize =
819        plan_root.gen_table_plan(context, table_name, database_id, schema_id, info, props)?;
820
821    let mut table = materialize.table().clone();
822    table.owner = session.user_id();
823
824    Ok((
825        ensure_sync_log_store_fragment_root(materialize.into()),
826        table,
827    ))
828}
829
830/// Generate stream plan for cdc table based on shared source.
831/// In replace workflow, the `table_id` is the id of the table to be replaced
832/// in create table workflow, the `table_id` is a placeholder will be filled in the Meta
833#[allow(clippy::too_many_arguments)]
834pub(crate) fn gen_create_table_plan_for_cdc_table(
835    context: OptimizerContextRef,
836    source: Arc<SourceCatalog>,
837    external_table_name: String,
838    column_defs: Vec<ColumnDef>,
839    source_watermarks: Vec<SourceWatermark>,
840    mut columns: Vec<ColumnCatalog>,
841    pk_names: Vec<String>,
842    pk_comparisons: Vec<CdcKeyComparison>,
843    cdc_with_options: WithOptionsSecResolved,
844    mut col_id_gen: ColumnIdGenerator,
845    on_conflict: Option<OnConflict>,
846    with_version_columns: Vec<String>,
847    include_column_options: IncludeOption,
848    table_name: ObjectName,
849    resolved_table_name: String, // table name without schema prefix
850    database_id: DatabaseId,
851    schema_id: SchemaId,
852    table_id: TableId,
853    engine: Engine,
854) -> Result<(PlanRef, TableCatalog)> {
855    let session = context.session_ctx().clone();
856
857    // append additional columns to the end
858    handle_addition_columns(
859        None,
860        &cdc_with_options,
861        include_column_options,
862        &mut columns,
863        true,
864    )?;
865
866    for c in &mut columns {
867        col_id_gen.generate(c)?;
868    }
869
870    let (mut columns, pk_column_ids, _row_id_index) =
871        bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
872
873    let watermark_descs = bind_source_watermark(
874        context.session_ctx(),
875        table_name.real_value(),
876        source_watermarks,
877        &columns,
878    )?;
879
880    // NOTES: In auto schema change, default value is not provided in column definition.
881    bind_sql_column_constraints(
882        context.session_ctx(),
883        table_name.real_value(),
884        &mut columns,
885        &column_defs,
886        &pk_column_ids,
887    )?;
888
889    let definition = context.normalized_sql().to_owned();
890
891    let pk_column_indices = {
892        let mut id_to_idx = HashMap::new();
893        columns.iter().enumerate().for_each(|(idx, c)| {
894            id_to_idx.insert(c.column_id(), idx);
895        });
896        // pk column id must exist in table columns.
897        pk_column_ids
898            .iter()
899            .map(|c| id_to_idx.get(c).copied().unwrap())
900            .collect_vec()
901    };
902    let table_pk = pk_column_indices
903        .iter()
904        .map(|idx| ColumnOrder::new(*idx, OrderType::ascending()))
905        .collect();
906
907    let (options, secret_refs) = cdc_with_options.into_parts();
908
909    let non_generated_column_descs = columns
910        .iter()
911        .filter(|&c| !c.is_generated())
912        .map(|c| c.column_desc.clone())
913        .collect_vec();
914    let non_generated_column_num = non_generated_column_descs.len();
915    let cdc_table_type = ExternalCdcTableType::from_properties(&options);
916    let cdc_table_desc = CdcTableDesc {
917        table_id,
918        source_id: source.id, // id of cdc source streaming job
919        external_table_name: external_table_name.clone(),
920        pk: table_pk,
921        pk_comparisons,
922        columns: non_generated_column_descs,
923        stream_key: pk_column_indices,
924        connect_properties: options,
925        secret_refs,
926    };
927
928    tracing::debug!(?cdc_table_desc, "create cdc table");
929    let options = build_cdc_scan_options_with_options(
930        context.with_options(),
931        cdc_table_desc.connect_properties.cdc_source_type(),
932    )?;
933
934    let logical_scan = LogicalCdcScan::create(
935        external_table_name.clone(),
936        Rc::new(cdc_table_desc),
937        context.clone(),
938        options,
939    );
940
941    let scan_node: LogicalPlanRef = logical_scan.into();
942    let required_cols = FixedBitSet::with_capacity(non_generated_column_num);
943    let plan_root = PlanRoot::new_with_logical_plan(
944        scan_node,
945        RequiredDist::Any,
946        Order::any(),
947        required_cols,
948        vec![],
949    );
950
951    let cdc_table_id_external_table_name = if let ExternalCdcTableType::Postgres = cdc_table_type
952        && let Some(normalized_table_name) =
953            normalize_simple_postgres_quoted_table_name(&external_table_name)
954    {
955        normalized_table_name
956    } else {
957        external_table_name
958    };
959    let cdc_table_id = build_cdc_table_id(source.id, &cdc_table_id_external_table_name);
960    let materialize = plan_root.gen_table_plan(
961        context,
962        resolved_table_name,
963        database_id,
964        schema_id,
965        CreateTableInfo {
966            columns,
967            pk_column_ids,
968            row_id_index: None,
969            watermark_descs,
970            source_catalog: Some((*source).clone()),
971            version: col_id_gen.into_version(),
972        },
973        CreateTableProps {
974            definition,
975            append_only: false,
976            on_conflict: on_conflict.into(),
977            with_version_columns,
978            webhook_info: None,
979            engine,
980        },
981    )?;
982
983    let mut table = materialize.table().clone();
984    table.owner = session.user_id();
985    table.cdc_table_id = Some(cdc_table_id);
986    table.cdc_table_type = Some(cdc_table_type);
987    Ok((
988        ensure_sync_log_store_fragment_root(materialize.into()),
989        table,
990    ))
991}
992
993#[allow(clippy::too_many_arguments)]
994pub(super) async fn handle_create_table_plan(
995    handler_args: HandlerArgs,
996    explain_options: ExplainOptions,
997    format_encode: Option<FormatEncodeOptions>,
998    cdc_table_info: Option<CdcTableInfo>,
999    table_name: &ObjectName,
1000    column_defs: Vec<ColumnDef>,
1001    wildcard_idx: Option<usize>,
1002    constraints: Vec<TableConstraint>,
1003    source_watermarks: Vec<SourceWatermark>,
1004    append_only: bool,
1005    on_conflict: Option<OnConflict>,
1006    with_version_columns: Vec<String>,
1007    include_column_options: IncludeOption,
1008    webhook_info: Option<WebhookSourceInfo>,
1009    engine: Engine,
1010) -> Result<(
1011    PlanRef,
1012    Option<SourceCatalog>,
1013    TableCatalog,
1014    TableJobType,
1015    Option<SourceId>,
1016)> {
1017    let col_id_gen = ColumnIdGenerator::new_initial();
1018    let format_encode = check_create_table_with_source(
1019        &handler_args.with_options,
1020        format_encode,
1021        &include_column_options,
1022        &cdc_table_info,
1023    )?;
1024    let webhook_info = webhook_info
1025        .map(|info| bind_webhook_info(&handler_args.session, &column_defs, info))
1026        .transpose()?;
1027
1028    let props = CreateTableProps {
1029        definition: handler_args.normalized_sql.clone(),
1030        append_only,
1031        on_conflict: on_conflict.into(),
1032        with_version_columns: with_version_columns.clone(),
1033        webhook_info,
1034        engine,
1035    };
1036
1037    let ((plan, source, table), job_type, shared_shource_id) = match (
1038        format_encode,
1039        cdc_table_info.as_ref(),
1040    ) {
1041        (Some(format_encode), None) => (
1042            gen_create_table_plan_with_source(
1043                handler_args,
1044                explain_options,
1045                table_name.clone(),
1046                column_defs,
1047                wildcard_idx,
1048                constraints,
1049                format_encode,
1050                source_watermarks,
1051                col_id_gen,
1052                include_column_options,
1053                props,
1054                SqlColumnStrategy::FollowChecked,
1055            )
1056            .await?,
1057            TableJobType::General,
1058            None,
1059        ),
1060        (None, None) => {
1061            let context = OptimizerContext::new(handler_args, explain_options);
1062            let (plan, table) = gen_create_table_plan(
1063                context,
1064                table_name.clone(),
1065                column_defs,
1066                constraints,
1067                col_id_gen,
1068                source_watermarks,
1069                props,
1070                false,
1071            )?;
1072
1073            ((plan, None, table), TableJobType::General, None)
1074        }
1075
1076        (None, Some(cdc_table)) => {
1077            sanity_check_for_table_on_cdc_source(
1078                append_only,
1079                &column_defs,
1080                &wildcard_idx,
1081                &constraints,
1082                &source_watermarks,
1083            )?;
1084
1085            generated_columns_check_for_cdc_table(&column_defs)?;
1086            not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
1087
1088            let session = &handler_args.session;
1089            let db_name = &session.database();
1090            let user_name = &session.user_name();
1091            let search_path = session.config().search_path();
1092            let (schema_name, resolved_table_name) =
1093                Binder::resolve_schema_qualified_name(db_name, table_name)?;
1094            let (database_id, schema_id) =
1095                session.get_database_and_schema_id_for_create(schema_name.clone())?;
1096
1097            // cdc table cannot be append-only
1098            let (source_schema, source_name) =
1099                Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
1100
1101            let source = {
1102                let catalog_reader = session.env().catalog_reader().read_guard();
1103                let schema_path =
1104                    SchemaPath::new(source_schema.as_deref(), &search_path, user_name);
1105
1106                let (source, _) = catalog_reader.get_source_by_name(
1107                    db_name,
1108                    schema_path,
1109                    source_name.as_str(),
1110                )?;
1111                source.clone()
1112            };
1113            check_cdc_source_select_privilege(session, &source)?;
1114            let (cdc_with_options, normalized_external_table_name) =
1115                derive_with_options_for_cdc_table(
1116                    &source.with_properties,
1117                    cdc_table.external_table_name.clone(),
1118                )?;
1119
1120            let (columns, pk_names, pk_comparisons) = match wildcard_idx {
1121                Some(_) => bind_cdc_table_schema_externally(cdc_with_options.clone()).await?,
1122                None => {
1123                    for column_def in &column_defs {
1124                        for option_def in &column_def.options {
1125                            if let ColumnOption::DefaultValue(_)
1126                            | ColumnOption::DefaultValueInternal { .. } = option_def.option
1127                            {
1128                                return Err(ErrorCode::NotSupported(
1129                                            "Default value for columns defined on the table created from a CDC source".into(),
1130                                            "Remove the default value expression in the column definitions".into(),
1131                                        )
1132                                            .into());
1133                            }
1134                        }
1135                    }
1136
1137                    let (columns, pk_names) =
1138                        bind_cdc_table_schema(&column_defs, &constraints, false)?;
1139                    let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1140                        cdc_with_options.clone(),
1141                        &pk_names,
1142                    ))
1143                    .await?;
1144
1145                    (columns, pk_names, pk_comparisons)
1146                }
1147            };
1148
1149            // CDC-table branch only: if Debezium column filters remove a PK column from the
1150            // change-event value, RisingWave reads NULL for that PK from the payload and silently
1151            // corrupts downstream (UPDATE turns into a fresh INSERT, DELETE no-ops). Plain
1152            // (non-CDC) tables don't hit this check.
1153            reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1154
1155            let context: OptimizerContextRef =
1156                OptimizerContext::new(handler_args, explain_options).into();
1157            let shared_source_id = source.id;
1158            let (plan, table) = gen_create_table_plan_for_cdc_table(
1159                context,
1160                source,
1161                normalized_external_table_name,
1162                column_defs,
1163                source_watermarks,
1164                columns,
1165                pk_names,
1166                pk_comparisons,
1167                cdc_with_options,
1168                col_id_gen,
1169                on_conflict,
1170                with_version_columns,
1171                include_column_options,
1172                table_name.clone(),
1173                resolved_table_name,
1174                database_id,
1175                schema_id,
1176                TableId::placeholder(),
1177                engine,
1178            )?;
1179
1180            (
1181                (plan, None, table),
1182                TableJobType::SharedCdcSource,
1183                Some(shared_source_id),
1184            )
1185        }
1186        (Some(_), Some(_)) => {
1187            return Err(ErrorCode::NotSupported(
1188                "Data format and encoding format doesn't apply to table created from a CDC source"
1189                    .into(),
1190                "Remove the FORMAT and ENCODE specification".into(),
1191            )
1192            .into());
1193        }
1194    };
1195    Ok((plan, source, table, job_type, shared_shource_id))
1196}
1197
1198// For both table from cdc source and table with cdc connector
1199fn generated_columns_check_for_cdc_table(columns: &Vec<ColumnDef>) -> Result<()> {
1200    let mut found_generated_column = false;
1201    for column in columns {
1202        let mut is_generated = false;
1203
1204        for option_def in &column.options {
1205            if let ColumnOption::GeneratedColumns(_) = option_def.option {
1206                is_generated = true;
1207                break;
1208            }
1209        }
1210
1211        if is_generated {
1212            found_generated_column = true;
1213        } else if found_generated_column {
1214            return Err(ErrorCode::NotSupported(
1215                "Non-generated column found after a generated column.".into(),
1216                "Ensure that all generated columns appear at the end of the cdc table definition."
1217                    .into(),
1218            )
1219            .into());
1220        }
1221    }
1222    Ok(())
1223}
1224
1225#[allow(clippy::too_many_arguments)]
1226pub async fn handle_create_table(
1227    handler_args: HandlerArgs,
1228    table_name: ObjectName,
1229    column_defs: Vec<ColumnDef>,
1230    wildcard_idx: Option<usize>,
1231    constraints: Vec<TableConstraint>,
1232    if_not_exists: bool,
1233    format_encode: Option<FormatEncodeOptions>,
1234    source_watermarks: Vec<SourceWatermark>,
1235    append_only: bool,
1236    on_conflict: Option<OnConflict>,
1237    with_version_columns: Vec<String>,
1238    cdc_table_info: Option<CdcTableInfo>,
1239    include_column_options: IncludeOption,
1240    webhook_info: Option<WebhookSourceInfo>,
1241    ast_engine: risingwave_sqlparser::ast::Engine,
1242) -> Result<RwPgResponse> {
1243    let session = handler_args.session.clone();
1244
1245    if append_only {
1246        session.notice_to_user("APPEND ONLY TABLE is currently an experimental feature.");
1247    }
1248
1249    session.check_cluster_limits().await?;
1250
1251    let engine = match ast_engine {
1252        risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1253        risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1254    };
1255
1256    if let Either::Right(resp) = session.check_relation_name_duplicated(
1257        table_name.clone(),
1258        StatementType::CREATE_TABLE,
1259        if_not_exists,
1260    )? {
1261        return Ok(resp);
1262    }
1263
1264    let (graph, source, hummock_table, job_type, shared_source_id) = {
1265        let (plan, source, table, job_type, shared_source_id) = Box::pin(handle_create_table_plan(
1266            handler_args.clone(),
1267            ExplainOptions::default(),
1268            format_encode,
1269            cdc_table_info,
1270            &table_name,
1271            column_defs.clone(),
1272            wildcard_idx,
1273            constraints.clone(),
1274            source_watermarks,
1275            append_only,
1276            on_conflict,
1277            with_version_columns,
1278            include_column_options,
1279            webhook_info,
1280            engine,
1281        ))
1282        .await?;
1283        tracing::trace!("table_plan: {:?}", plan.explain_to_string());
1284
1285        let graph = build_graph(plan, Some(GraphJobType::Table))?;
1286
1287        (graph, source, table, job_type, shared_source_id)
1288    };
1289
1290    tracing::trace!(
1291        "name={}, graph=\n{}",
1292        table_name,
1293        serde_json::to_string_pretty(&graph).unwrap()
1294    );
1295
1296    let dependencies = shared_source_id
1297        .map(|id| HashSet::from([id.as_object_id()]))
1298        .unwrap_or_default();
1299
1300    // Handle engine
1301    match engine {
1302        Engine::Hummock => {
1303            let catalog_writer = session.catalog_writer()?;
1304            let action = match job_type {
1305                TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1306                _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1307            };
1308            execute_with_long_running_notification(
1309                catalog_writer.create_table(
1310                    source.map(|s| s.to_prost()),
1311                    hummock_table.to_prost(),
1312                    graph,
1313                    job_type,
1314                    if_not_exists,
1315                    dependencies,
1316                ),
1317                &session,
1318                "CREATE TABLE",
1319                action,
1320            )
1321            .await?;
1322        }
1323        Engine::Iceberg => {
1324            let hummock_table_name = hummock_table.name.clone();
1325            session.create_staging_table(hummock_table.clone());
1326            let res = Box::pin(create_iceberg_engine_table(
1327                session.clone(),
1328                handler_args,
1329                source.map(|s| s.to_prost()),
1330                hummock_table,
1331                graph,
1332                table_name,
1333                job_type,
1334                if_not_exists,
1335            ))
1336            .await;
1337            session.drop_staging_table(&hummock_table_name);
1338            res?
1339        }
1340    }
1341
1342    Ok(PgResponse::empty_result(StatementType::CREATE_TABLE))
1343}
1344
1345fn build_iceberg_engine_sink_options(
1346    mut sink_options: BTreeMap<String, String>,
1347    user_options: &WithOptions,
1348    table: &TableCatalog,
1349    primary_key: &[String],
1350) -> Result<BTreeMap<String, String>> {
1351    sink_options.extend(
1352        user_options
1353            .iter()
1354            .filter(|(key, _)| is_iceberg_engine_option(key))
1355            .map(|(key, value)| (key.clone(), value.clone())),
1356    );
1357
1358    sink_options
1359        .entry(ENABLE_COMPACTION.to_owned())
1360        .or_insert_with(|| "true".to_owned());
1361    sink_options.insert(
1362        "type".to_owned(),
1363        if table.append_only {
1364            "append-only"
1365        } else {
1366            "upsert"
1367        }
1368        .to_owned(),
1369    );
1370
1371    // Supply the table primary key while parsing the typed config. In PK-index mode the
1372    // planner derives it from the upstream stream key, so it is removed again below.
1373    if !table.append_only {
1374        sink_options.insert("primary_key".to_owned(), primary_key.join(","));
1375    }
1376
1377    sink_options.insert("create_table_if_not_exists".to_owned(), "true".to_owned());
1378    sink_options.insert("is_exactly_once".to_owned(), "true".to_owned());
1379
1380    let config = IcebergConfig::from_btreemap(sink_options.clone())?;
1381
1382    // Both merge-on-read and copy-on-write initialize an equality-delete writer for upserts.
1383    // Reject unsupported key types before creating the remote Iceberg table.
1384    if !table.append_only && !config.enable_pk_index {
1385        for pk in table.pk() {
1386            let column = &table.columns()[pk.column_index];
1387            let data_type = column.data_type();
1388            if data_type.is_composite()
1389                || matches!(data_type, DataType::Float32 | DataType::Float64)
1390            {
1391                return Err(ErrorCode::NotSupported(
1392                    format!(
1393                        "Iceberg engine table primary key column \"{}\" has unsupported type {} for equality deletes",
1394                        column.name(), data_type
1395                    ),
1396                    "Use non-floating-point scalar primary key columns.".to_owned(),
1397                )
1398                .into());
1399            }
1400        }
1401    }
1402
1403    // Engine tables own their Iceberg maintenance policy, so enable manifest rewrites by default
1404    // whenever the table format supports them. Keep V3 disabled until rewrites preserve row lineage.
1405    if config.table_format_version() < FormatVersion::V3 {
1406        sink_options
1407            .entry(ENABLE_MANIFEST_REWRITE.to_owned())
1408            .or_insert_with(|| "true".to_owned());
1409    }
1410
1411    if let Some(partition_by) = &config.partition_by {
1412        let mut partition_columns = vec![];
1413        for (column, _) in parse_partition_by_exprs(partition_by.clone())? {
1414            table
1415                .columns()
1416                .iter()
1417                .find(|col| col.name().eq_ignore_ascii_case(&column))
1418                .ok_or_else(|| {
1419                    ErrorCode::InvalidInputSyntax(format!(
1420                        "Partition source column does not exist in schema: {}",
1421                        column
1422                    ))
1423                })?;
1424
1425            partition_columns.push(column);
1426        }
1427
1428        ensure_partition_columns_are_prefix_of_primary_key(&partition_columns, primary_key)
1429            .map_err(|_| {
1430                ErrorCode::InvalidInputSyntax(
1431                    "The partition columns should be the prefix of the primary key".to_owned(),
1432                )
1433            })?;
1434    }
1435
1436    if let Some(order_key) = &config.order_key {
1437        validate_order_key_columns(order_key, table.columns().iter().map(|col| col.name()))
1438            .map_err(|err| ErrorCode::InvalidInputSyntax(err.to_report_string()))?;
1439    }
1440
1441    if config.enable_pk_index {
1442        sink_options.remove("primary_key");
1443    } else {
1444        sink_options.insert(AUTO_SCHEMA_CHANGE_KEY.to_owned(), "true".to_owned());
1445    }
1446
1447    Ok(sink_options)
1448}
1449
1450/// Iceberg table engine is composed of hummock table, iceberg sink and iceberg source.
1451///
1452/// 1. fetch iceberg engine options from the meta node. Or use iceberg engine connection provided by users.
1453/// 2. create a hummock table
1454/// 3. create an iceberg sink
1455/// 4. create an iceberg source
1456///
1457/// See <https://github.com/risingwavelabs/risingwave/issues/21586> for an architecture diagram.
1458#[allow(clippy::too_many_arguments)]
1459pub async fn create_iceberg_engine_table(
1460    session: Arc<SessionImpl>,
1461    handler_args: HandlerArgs,
1462    mut source: Option<PbSource>,
1463    table: TableCatalog,
1464    graph: StreamFragmentGraph,
1465    table_name: ObjectName,
1466    job_type: PbTableJobType,
1467    if_not_exists: bool,
1468) -> Result<()> {
1469    let rw_db_name = session
1470        .env()
1471        .catalog_reader()
1472        .read_guard()
1473        .get_database_by_id(table.database_id)?
1474        .name()
1475        .to_owned();
1476    let rw_schema_name = session
1477        .env()
1478        .catalog_reader()
1479        .read_guard()
1480        .get_schema_by_id(table.database_id, table.schema_id)?
1481        .name()
1482        .clone();
1483    let iceberg_catalog_name = rw_db_name.clone();
1484    let iceberg_database_name = rw_schema_name.clone();
1485    let iceberg_table_name = table_name.0.last().unwrap().real_value();
1486
1487    let iceberg_engine_connection: String = session.config().iceberg_engine_connection();
1488    let sink_decouple = session.config().sink_decouple();
1489    if matches!(sink_decouple, SinkDecouple::Disable) {
1490        bail!(
1491            "Iceberg engine table only supports with sink decouple, try `set sink_decouple = true` to resolve it"
1492        );
1493    }
1494
1495    let mut connection_ref = BTreeMap::new();
1496    let with_common = if iceberg_engine_connection.is_empty() {
1497        bail!("to use iceberg engine table, the variable `iceberg_engine_connection` must be set.");
1498    } else {
1499        let parts: Vec<&str> = iceberg_engine_connection.split('.').collect();
1500        assert_eq!(parts.len(), 2);
1501        let connection_catalog =
1502            session.get_connection_by_name(Some(parts[0].to_owned()), parts[1])?;
1503        if let ConnectionInfo::ConnectionParams(params) = &connection_catalog.info {
1504            if params.connection_type == ConnectionType::Iceberg as i32 {
1505                // With iceberg engine connection:
1506                connection_ref.insert(
1507                    "connection".to_owned(),
1508                    ConnectionRefValue {
1509                        connection_name: ObjectName::from(vec![
1510                            Ident::from(parts[0]),
1511                            Ident::from(parts[1]),
1512                        ]),
1513                    },
1514                );
1515
1516                let mut with_common = BTreeMap::new();
1517                with_common.insert("connector".to_owned(), "iceberg".to_owned());
1518                with_common.insert("database.name".to_owned(), iceberg_database_name);
1519                with_common.insert("table.name".to_owned(), iceberg_table_name);
1520
1521                let hosted_catalog = params
1522                    .properties
1523                    .get("hosted_catalog")
1524                    .map(|s| s.eq_ignore_ascii_case("true"))
1525                    .unwrap_or(false);
1526                if hosted_catalog {
1527                    let meta_client = session.env().meta_client();
1528                    let meta_store_endpoint = meta_client.get_meta_store_endpoint().await?;
1529
1530                    let meta_store_endpoint =
1531                        url::Url::parse(&meta_store_endpoint).map_err(|_| {
1532                            ErrorCode::InternalError(
1533                                "failed to parse the meta store endpoint".to_owned(),
1534                            )
1535                        })?;
1536                    let meta_store_backend = meta_store_endpoint.scheme().to_owned();
1537                    let meta_store_user = meta_store_endpoint.username().to_owned();
1538                    let meta_store_password = match meta_store_endpoint.password() {
1539                        Some(password) => percent_decode_str(password)
1540                            .decode_utf8()
1541                            .map_err(|_| {
1542                                ErrorCode::InternalError(
1543                                    "failed to parse password from meta store endpoint".to_owned(),
1544                                )
1545                            })?
1546                            .into_owned(),
1547                        None => "".to_owned(),
1548                    };
1549                    let meta_store_host = meta_store_endpoint
1550                        .host_str()
1551                        .ok_or_else(|| {
1552                            ErrorCode::InternalError(
1553                                "failed to parse host from meta store endpoint".to_owned(),
1554                            )
1555                        })?
1556                        .to_owned();
1557                    let meta_store_port = meta_store_endpoint.port().ok_or_else(|| {
1558                        ErrorCode::InternalError(
1559                            "failed to parse port from meta store endpoint".to_owned(),
1560                        )
1561                    })?;
1562                    let meta_store_database = meta_store_endpoint
1563                        .path()
1564                        .trim_start_matches('/')
1565                        .to_owned();
1566
1567                    let Ok(meta_backend) = MetaBackend::from_str(&meta_store_backend, true) else {
1568                        bail!("failed to parse meta backend: {}", meta_store_backend);
1569                    };
1570
1571                    let catalog_uri = match meta_backend {
1572                        MetaBackend::Postgres => {
1573                            format!(
1574                                "jdbc:postgresql://{}:{}/{}",
1575                                meta_store_host, meta_store_port, meta_store_database
1576                            )
1577                        }
1578                        MetaBackend::Mysql => {
1579                            format!(
1580                                "jdbc:mysql://{}:{}/{}",
1581                                meta_store_host, meta_store_port, meta_store_database
1582                            )
1583                        }
1584                        MetaBackend::Sqlite | MetaBackend::Sql | MetaBackend::Mem => {
1585                            bail!(
1586                                "Unsupported meta backend for iceberg engine table: {}",
1587                                meta_store_backend
1588                            );
1589                        }
1590                    };
1591
1592                    with_common.insert("catalog.type".to_owned(), "jdbc".to_owned());
1593                    with_common.insert("catalog.uri".to_owned(), catalog_uri);
1594                    with_common.insert("catalog.jdbc.user".to_owned(), meta_store_user);
1595                    with_common.insert("catalog.jdbc.password".to_owned(), meta_store_password);
1596                    with_common.insert("catalog.name".to_owned(), iceberg_catalog_name);
1597                }
1598
1599                with_common
1600            } else {
1601                return Err(RwError::from(ErrorCode::InvalidParameterValue(
1602                    "Only iceberg connection could be used in iceberg engine".to_owned(),
1603                )));
1604            }
1605        } else {
1606            return Err(RwError::from(ErrorCode::InvalidParameterValue(
1607                "Private Link Service has been deprecated. Please create a new connection instead."
1608                    .to_owned(),
1609            )));
1610        }
1611    };
1612
1613    // Iceberg sinks require a primary key, if none is provided, we will use the _row_id column
1614    // Fetch primary key from columns
1615    let mut pks = table
1616        .pk_column_names()
1617        .iter()
1618        .map(|c| c.to_string())
1619        .collect::<Vec<String>>();
1620
1621    // For the table without primary key. We will use `_row_id` as primary key.
1622    if pks.len() == 1 && pks[0].eq(ROW_ID_COLUMN_NAME) {
1623        pks = vec![RISINGWAVE_ICEBERG_ROW_ID.to_owned()];
1624    }
1625
1626    let sink_from = CreateSink::From(table_name.clone());
1627
1628    let mut sink_name = table_name.clone();
1629    *sink_name.0.last_mut().unwrap() = Ident::from(
1630        (ICEBERG_SINK_PREFIX.to_owned() + &sink_name.0.last().unwrap().real_value()).as_str(),
1631    );
1632    let create_sink_stmt = CreateSinkStatement {
1633        or_replace: false,
1634        if_not_exists: false,
1635        sink_name,
1636        with_properties: WithProperties(vec![]),
1637        sink_from,
1638        columns: vec![],
1639        emit_mode: None,
1640        sink_schema: None,
1641        into_table_name: None,
1642    };
1643
1644    let mut sink_handler_args = handler_args.clone();
1645
1646    let sink_with = build_iceberg_engine_sink_options(
1647        with_common.clone(),
1648        &handler_args.with_options,
1649        &table,
1650        &pks,
1651    )?;
1652
1653    if let Some(source) = source.as_mut() {
1654        source
1655            .with_properties
1656            .retain(|key, _| !is_iceberg_engine_option(key));
1657    }
1658
1659    // sink_with.insert(SINK_SNAPSHOT_OPTION.to_owned(), "false".to_owned());
1660    //
1661    // Note: in theory, we don't need to backfill from the table to the sink,
1662    // but we don't have atomic DDL now https://github.com/risingwavelabs/risingwave/issues/21863
1663    // so it may have potential data loss problem on the first barrier.
1664    //
1665    // For non-append-only table, we can always solve it by the initial sink with backfill, since
1666    // data will be present in hummock table.
1667    //
1668    // For append-only table, we need to be more careful.
1669    //
1670    // The possible cases for a table:
1671    // - For table without connector: it doesn't matter, since there's no data before the table is created
1672    // - For table with connector: we workarounded it by setting SOURCE_RATE_LIMIT to 0
1673    //   + If we support blocking DDL for table with connector, we need to be careful.
1674    // - For table with an upstream job: Specifically, CDC table from shared CDC source.
1675    //   + Data may come from both upstream connector, and CDC table backfill, so we need to pause both of them.
1676    //   + For now we don't support APPEND ONLY CDC table, so it's safe.
1677
1678    sink_handler_args.with_options =
1679        WithOptions::new(sink_with, Default::default(), connection_ref.clone());
1680    let SinkPlanContext {
1681        sink_plan,
1682        sink_catalog,
1683        ..
1684    } = gen_sink_plan(sink_handler_args, create_sink_stmt, None, true).await?;
1685    let sink_graph = build_graph(sink_plan, Some(GraphJobType::Sink))?;
1686
1687    let mut source_name = table_name.clone();
1688    *source_name.0.last_mut().unwrap() = Ident::from(
1689        (ICEBERG_SOURCE_PREFIX.to_owned() + &source_name.0.last().unwrap().real_value()).as_str(),
1690    );
1691    let create_source_stmt = CreateSourceStatement {
1692        temporary: false,
1693        if_not_exists: false,
1694        columns: vec![],
1695        source_name,
1696        wildcard_idx: Some(0),
1697        constraints: vec![],
1698        with_properties: WithProperties(vec![]),
1699        format_encode: CompatibleFormatEncode::V2(FormatEncodeOptions::none()),
1700        source_watermarks: vec![],
1701        include_column_options: vec![],
1702        cdc_table_info: None,
1703    };
1704
1705    let mut source_handler_args = handler_args.clone();
1706    let source_with = with_common;
1707    source_handler_args.with_options =
1708        WithOptions::new(source_with, Default::default(), connection_ref);
1709
1710    let overwrite_options = OverwriteOptions::new(&mut source_handler_args);
1711    let format_encode = create_source_stmt.format_encode.into_v2_with_warning();
1712    let (with_properties, refresh_mode) =
1713        bind_connector_props(&source_handler_args, &format_encode, true)?;
1714
1715    // Create iceberg sink table, used for iceberg source column binding. See `bind_columns_from_source_for_non_cdc` for more details.
1716    // 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.
1717    let (iceberg_catalog, table_identifier, table_created) = {
1718        let sink_param = SinkParam::try_from_sink_catalog(sink_catalog.clone())?;
1719        let iceberg_sink = IcebergSink::try_from(sink_param)?;
1720        let table_created = iceberg_sink.create_table_if_not_exists().await?;
1721
1722        let iceberg_catalog = iceberg_sink.config.create_catalog().await?;
1723        let table_identifier = iceberg_sink.config.full_table_name()?;
1724        (iceberg_catalog, table_identifier, table_created)
1725    };
1726
1727    let create_source_type = CreateSourceType::for_newly_created(&session, &*with_properties);
1728    let (columns_from_resolve_source, source_info) = bind_columns_from_source(
1729        &session,
1730        &format_encode,
1731        Either::Left(&with_properties),
1732        create_source_type,
1733    )
1734    .await?;
1735    let mut col_id_gen = ColumnIdGenerator::new_initial();
1736
1737    let iceberg_source_catalog = bind_create_source_or_table_with_connector(
1738        source_handler_args,
1739        create_source_stmt.source_name,
1740        format_encode,
1741        with_properties,
1742        &create_source_stmt.columns,
1743        create_source_stmt.constraints,
1744        create_source_stmt.wildcard_idx,
1745        create_source_stmt.source_watermarks,
1746        columns_from_resolve_source,
1747        source_info,
1748        create_source_stmt.include_column_options,
1749        &mut col_id_gen,
1750        create_source_type,
1751        overwrite_options.source_rate_limit,
1752        SqlColumnStrategy::FollowChecked,
1753        refresh_mode,
1754    )
1755    .await?;
1756
1757    // before we create the table, ensure the JVM is initialized as we use jdbc catalog right now.
1758    // If JVM isn't initialized successfully, current not atomic ddl will result in a partially created iceberg engine table.
1759    let _ = Jvm::get_or_init()?;
1760
1761    let catalog_writer = session.catalog_writer()?;
1762    let action = match job_type {
1763        TableJobType::SharedCdcSource => LongRunningNotificationAction::MonitorBackfillJob,
1764        _ => LongRunningNotificationAction::DiagnoseBarrierLatency,
1765    };
1766    let res = execute_with_long_running_notification(
1767        catalog_writer.create_iceberg_table(
1768            PbTableJobInfo {
1769                source,
1770                table: Some(table.to_prost()),
1771                fragment_graph: Some(graph),
1772                job_type: job_type as _,
1773            },
1774            PbSinkJobInfo {
1775                sink: Some(sink_catalog.to_proto()),
1776                fragment_graph: Some(sink_graph),
1777            },
1778            iceberg_source_catalog.to_prost(),
1779            if_not_exists,
1780        ),
1781        &session,
1782        "CREATE TABLE",
1783        action,
1784    )
1785    .await;
1786
1787    if res.is_err() {
1788        // Only clean up an iceberg table this statement created; a pre-existing
1789        // external table must survive a failed CREATE.
1790        if table_created {
1791            let _ = iceberg_catalog
1792                .drop_table(&table_identifier)
1793                .await
1794                .inspect_err(|err| {
1795                    tracing::error!(
1796                        "failed to drop iceberg table {} after create iceberg engine table failed: {}",
1797                        table_identifier,
1798                        err.as_report()
1799                    );
1800                });
1801        }
1802        res?
1803    }
1804
1805    Ok(())
1806}
1807
1808pub fn check_create_table_with_source(
1809    with_options: &WithOptions,
1810    format_encode: Option<FormatEncodeOptions>,
1811    include_column_options: &IncludeOption,
1812    cdc_table_info: &Option<CdcTableInfo>,
1813) -> Result<Option<FormatEncodeOptions>> {
1814    // skip check for cdc table
1815    if cdc_table_info.is_some() {
1816        return Ok(format_encode);
1817    }
1818    let defined_source = with_options.is_source_connector();
1819
1820    if !include_column_options.is_empty() && !defined_source {
1821        return Err(ErrorCode::InvalidInputSyntax(
1822            "INCLUDE should be used with a connector".to_owned(),
1823        )
1824        .into());
1825    }
1826    if defined_source {
1827        format_encode.as_ref().ok_or_else(|| {
1828            ErrorCode::InvalidInputSyntax("Please specify a source schema using FORMAT".to_owned())
1829        })?;
1830    }
1831    Ok(format_encode)
1832}
1833
1834fn ensure_partition_columns_are_prefix_of_primary_key(
1835    partition_columns: &[String],
1836    primary_key_columns: &[String],
1837) -> std::result::Result<(), String> {
1838    if partition_columns.len() > primary_key_columns.len() {
1839        return Err("Partition columns cannot be longer than primary key columns.".to_owned());
1840    }
1841
1842    for (i, partition_col) in partition_columns.iter().enumerate() {
1843        if primary_key_columns.get(i) != Some(partition_col) {
1844            return Err(format!(
1845                "Partition column '{}' is not a prefix of the primary key.",
1846                partition_col
1847            ));
1848        }
1849    }
1850
1851    Ok(())
1852}
1853
1854#[allow(clippy::too_many_arguments)]
1855pub async fn generate_stream_graph_for_replace_table(
1856    _session: &Arc<SessionImpl>,
1857    table_name: ObjectName,
1858    original_catalog: &Arc<TableCatalog>,
1859    handler_args: HandlerArgs,
1860    statement: Statement,
1861    col_id_gen: ColumnIdGenerator,
1862    sql_column_strategy: SqlColumnStrategy,
1863) -> Result<(
1864    StreamFragmentGraph,
1865    TableCatalog,
1866    Option<SourceCatalog>,
1867    TableJobType,
1868)> {
1869    let Statement::CreateTable {
1870        columns,
1871        constraints,
1872        source_watermarks,
1873        append_only,
1874        on_conflict,
1875        with_version_columns,
1876        wildcard_idx,
1877        cdc_table_info,
1878        format_encode,
1879        include_column_options,
1880        engine,
1881        with_options,
1882        ..
1883    } = statement
1884    else {
1885        panic!("unexpected statement type: {:?}", statement);
1886    };
1887
1888    let format_encode = format_encode
1889        .clone()
1890        .map(|format_encode| format_encode.into_v2_with_warning());
1891
1892    let engine = match engine {
1893        risingwave_sqlparser::ast::Engine::Hummock => Engine::Hummock,
1894        risingwave_sqlparser::ast::Engine::Iceberg => Engine::Iceberg,
1895    };
1896
1897    let is_drop_connector =
1898        original_catalog.associated_source_id().is_some() && format_encode.is_none();
1899    if is_drop_connector {
1900        debug_assert!(
1901            include_column_options.is_empty()
1902                && with_options
1903                    .iter()
1904                    .all(|opt| opt.name.real_value().to_lowercase() != "connector")
1905        );
1906    }
1907
1908    let props = CreateTableProps {
1909        definition: handler_args.normalized_sql.clone(),
1910        append_only,
1911        on_conflict: on_conflict.into(),
1912        with_version_columns: with_version_columns
1913            .iter()
1914            .map(|col| col.real_value())
1915            .collect(),
1916        webhook_info: original_catalog.webhook_info.clone(),
1917        engine,
1918    };
1919
1920    let ((plan, mut source, mut table), job_type) = match (format_encode, cdc_table_info.as_ref()) {
1921        (Some(format_encode), None) => (
1922            gen_create_table_plan_with_source(
1923                handler_args,
1924                ExplainOptions::default(),
1925                table_name,
1926                columns,
1927                wildcard_idx,
1928                constraints,
1929                format_encode,
1930                source_watermarks,
1931                col_id_gen,
1932                include_column_options,
1933                props,
1934                sql_column_strategy,
1935            )
1936            .await?,
1937            TableJobType::General,
1938        ),
1939        (None, None) => {
1940            let context = OptimizerContext::from_handler_args(handler_args);
1941            let (plan, table) = gen_create_table_plan(
1942                context,
1943                table_name,
1944                columns,
1945                constraints,
1946                col_id_gen,
1947                source_watermarks,
1948                props,
1949                true,
1950            )?;
1951            ((plan, None, table), TableJobType::General)
1952        }
1953        (None, Some(cdc_table)) => {
1954            sanity_check_for_table_on_cdc_source(
1955                append_only,
1956                &columns,
1957                &wildcard_idx,
1958                &constraints,
1959                &source_watermarks,
1960            )?;
1961
1962            let session = &handler_args.session;
1963            let (source, resolved_table_name) =
1964                get_source_and_resolved_table_name(session, cdc_table.clone(), table_name.clone())?;
1965
1966            let (cdc_with_options, normalized_external_table_name) =
1967                derive_with_options_for_cdc_table(
1968                    &source.with_properties,
1969                    cdc_table.external_table_name.clone(),
1970                )?;
1971
1972            let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
1973            let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1974                cdc_with_options.clone(),
1975                &pk_names,
1976            ))
1977            .await?;
1978
1979            // CDC-table branch only: see the comment at the symmetric call site in
1980            // `handle_create_table_plan`. Plain (non-CDC) tables don't hit this check.
1981            reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1982
1983            let context: OptimizerContextRef =
1984                OptimizerContext::new(handler_args, ExplainOptions::default()).into();
1985            let (plan, table) = gen_create_table_plan_for_cdc_table(
1986                context,
1987                source,
1988                normalized_external_table_name,
1989                columns,
1990                source_watermarks,
1991                column_catalogs,
1992                pk_names,
1993                pk_comparisons,
1994                cdc_with_options,
1995                col_id_gen,
1996                on_conflict,
1997                with_version_columns
1998                    .iter()
1999                    .map(|col| col.real_value())
2000                    .collect(),
2001                include_column_options,
2002                table_name,
2003                resolved_table_name,
2004                original_catalog.database_id,
2005                original_catalog.schema_id,
2006                original_catalog.id(),
2007                engine,
2008            )?;
2009
2010            ((plan, None, table), TableJobType::SharedCdcSource)
2011        }
2012        (Some(_), Some(_)) => {
2013            return Err(ErrorCode::NotSupported(
2014                "Data format and encoding format doesn't apply to table created from a CDC source"
2015                    .into(),
2016                "Remove the FORMAT and ENCODE specification".into(),
2017            )
2018            .into());
2019        }
2020    };
2021
2022    if table.pk_column_ids() != original_catalog.pk_column_ids() {
2023        Err(ErrorCode::InvalidInputSyntax(
2024            "alter primary key of table is not supported".to_owned(),
2025        ))?
2026    }
2027
2028    let graph = build_graph(plan, Some(GraphJobType::Table))?;
2029
2030    // Fill the original table ID.
2031    table.id = original_catalog.id();
2032    if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2033        table.associated_source_id = Some(source_id);
2034
2035        let source = source.as_mut().unwrap();
2036        source.id = source_id;
2037        source.associated_table_id = Some(table.id());
2038    }
2039
2040    Ok((graph, table, source, job_type))
2041}
2042
2043fn get_source_and_resolved_table_name(
2044    session: &Arc<SessionImpl>,
2045    cdc_table: CdcTableInfo,
2046    table_name: ObjectName,
2047) -> Result<(Arc<SourceCatalog>, String)> {
2048    let db_name = &session.database();
2049    let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2050
2051    let (source_schema, source_name) =
2052        Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2053
2054    let source = {
2055        let catalog_reader = session.env().catalog_reader().read_guard();
2056        let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2057        let (source, _) = catalog_reader.get_source_by_name(
2058            db_name,
2059            SchemaPath::Name(schema_name.as_str()),
2060            source_name.as_str(),
2061        )?;
2062        source.clone()
2063    };
2064    check_cdc_source_select_privilege(session, &source)?;
2065
2066    Ok((source, resolved_table_name))
2067}
2068
2069pub(crate) fn check_cdc_source_select_privilege(
2070    session: &SessionImpl,
2071    source: &SourceCatalog,
2072) -> Result<()> {
2073    session.check_privileges(&[ObjectCheckItem::new(
2074        source.owner,
2075        AclMode::Select,
2076        source.name.clone(),
2077        source.id,
2078    )])
2079}
2080
2081// validate the webhook_info and also bind the webhook_info to protobuf
2082fn bind_webhook_info(
2083    session: &Arc<SessionImpl>,
2084    column_defs: &[ColumnDef],
2085    webhook_info: WebhookSourceInfo,
2086) -> Result<PbWebhookSourceInfo> {
2087    let WebhookSourceInfo {
2088        secret_ref,
2089        signature_expr,
2090        wait_for_persistence,
2091        is_batched,
2092    } = webhook_info;
2093
2094    for column in column_defs {
2095        for option_def in &column.options {
2096            match option_def.option {
2097                ColumnOption::Null => {}
2098                ColumnOption::GeneratedColumns(_) => {
2099                    return Err(ErrorCode::InvalidInputSyntax(
2100                        "generated columns are not supported for webhook tables".to_owned(),
2101                    )
2102                    .into());
2103                }
2104                ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2105                    return Err(ErrorCode::InvalidInputSyntax(
2106                        "default values are not supported for webhook tables".to_owned(),
2107                    )
2108                    .into());
2109                }
2110                ColumnOption::NotNull
2111                | ColumnOption::Unique { .. }
2112                | ColumnOption::ForeignKey { .. }
2113                | ColumnOption::Check(_)
2114                | ColumnOption::DialectSpecific(_) => {
2115                    return Err(ErrorCode::InvalidInputSyntax(
2116                        "only NULL column option is supported for webhook tables".to_owned(),
2117                    )
2118                    .into());
2119                }
2120            }
2121        }
2122    }
2123
2124    // validate secret_ref
2125    let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2126        let db_name = &session.database();
2127        let (schema_name, secret_name) =
2128            Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2129        let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2130        (
2131            Some(PbSecretRef {
2132                secret_id: secret_catalog.id,
2133                ref_as: match secret_ref.ref_as {
2134                    SecretRefAsType::Text => PbRefAsType::Text,
2135                    SecretRefAsType::File => PbRefAsType::File,
2136                }
2137                .into(),
2138            }),
2139            Some(secret_name),
2140        )
2141    } else {
2142        (None, None)
2143    };
2144
2145    let signature_expr = if let Some(signature_expr) = signature_expr {
2146        let payload_name = if column_defs.len() == 1
2147            && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
2148        {
2149            column_defs[0].name.real_value()
2150        } else {
2151            WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
2152        };
2153        let secure_compare_context = SecureCompareContext {
2154            payload_name,
2155            secret_name,
2156        };
2157        let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
2158        let expr = binder.bind_expr(&signature_expr)?;
2159
2160        // validate expr, ensuring it is SECURE_COMPARE()
2161        if expr.as_function_call().is_none()
2162            || expr.as_function_call().unwrap().func_type()
2163                != crate::optimizer::plan_node::generic::ExprType::SecureCompare
2164        {
2165            return Err(ErrorCode::InvalidInputSyntax(
2166                "The signature verification function must be SECURE_COMPARE()".to_owned(),
2167            )
2168            .into());
2169        }
2170
2171        Some(expr.to_expr_proto())
2172    } else {
2173        session.notice_to_user(
2174            "VALIDATE clause is strongly recommended for safety or production usages",
2175        );
2176        None
2177    };
2178
2179    let pb_webhook_info = PbWebhookSourceInfo {
2180        secret_ref: pb_secret_ref,
2181        signature_expr,
2182        wait_for_persistence,
2183        is_batched,
2184    };
2185
2186    Ok(pb_webhook_info)
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191    use risingwave_common::catalog::{
2192        DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
2193    };
2194    use risingwave_common::types::StructType;
2195
2196    use super::*;
2197    use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
2198
2199    #[tokio::test]
2200    async fn test_iceberg_engine_primary_key_types() {
2201        let frontend = LocalFrontend::new(Default::default()).await;
2202        for (primary_key, append_only, pk_index, supported) in [
2203            ("id, period", false, false, false),
2204            ("id, items", false, false, false),
2205            ("id, mapping", false, false, false),
2206            ("id, f", false, false, false),
2207            ("id, d", false, false, false),
2208            ("id", false, false, true),
2209            ("id, ts", false, false, true),
2210            ("", false, false, true),
2211            ("id, period", true, false, true),
2212            ("id, period", false, true, true),
2213        ] {
2214            let pk_clause = if primary_key.is_empty() {
2215                String::new()
2216            } else {
2217                format!(", PRIMARY KEY ({primary_key})")
2218            };
2219            frontend
2220                .run_sql(&format!(
2221                    "CREATE TABLE t (id INT, period STRUCT<start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ>, \
2222                     items INT[], mapping MAP(INT, INT), f REAL, d DOUBLE PRECISION, ts TIMESTAMPTZ \
2223                     {pk_clause}) {}",
2224                    if append_only { "APPEND ONLY" } else { "" }
2225                ))
2226                .await
2227                .unwrap();
2228            let session = frontend.session_ref();
2229            let table = session
2230                .env()
2231                .catalog_reader()
2232                .read_guard()
2233                .get_created_table_by_name(
2234                    DEFAULT_DATABASE_NAME,
2235                    SchemaPath::Name(DEFAULT_SCHEMA_NAME),
2236                    "t",
2237                )
2238                .unwrap()
2239                .0
2240                .clone();
2241            let pks = table
2242                .pk_column_names()
2243                .into_iter()
2244                .map(str::to_owned)
2245                .collect_vec();
2246            for write_mode in ["merge-on-read", "copy-on-write"] {
2247                if write_mode == "copy-on-write" && (append_only || pk_index) {
2248                    continue;
2249                }
2250                let options = BTreeMap::from([
2251                    ("connector".to_owned(), "iceberg".to_owned()),
2252                    ("catalog.type".to_owned(), "storage".to_owned()),
2253                    (
2254                        "warehouse.path".to_owned(),
2255                        "s3://test/warehouse".to_owned(),
2256                    ),
2257                    ("database.name".to_owned(), "public".to_owned()),
2258                    ("table.name".to_owned(), "t".to_owned()),
2259                    ("write_mode".to_owned(), write_mode.to_owned()),
2260                    ("enable_pk_index".to_owned(), pk_index.to_string()),
2261                ]);
2262                let result = build_iceberg_engine_sink_options(
2263                    options,
2264                    &WithOptions::default(),
2265                    &table,
2266                    &pks,
2267                );
2268                if supported {
2269                    result.unwrap();
2270                } else {
2271                    let err = result.unwrap_err().to_report_string();
2272                    let column = primary_key.split(", ").last().unwrap();
2273                    assert!(
2274                        err.contains(&format!(
2275                            "Iceberg engine table primary key column \"{column}\" has unsupported type"
2276                        )),
2277                        "{write_mode}: {err}"
2278                    );
2279                }
2280            }
2281            frontend.run_sql("DROP TABLE t").await.unwrap();
2282        }
2283    }
2284
2285    #[tokio::test]
2286    async fn test_cdc_relations_require_select_privilege_on_source() {
2287        let frontend = LocalFrontend::new(Default::default()).await;
2288        // PostgreSQL CDC with an explicit schema avoids querying upstream PK metadata,
2289        // so this privilege test does not require an external database.
2290        frontend
2291            .run_sql(
2292                r#"
2293                CREATE SOURCE cdc_source WITH (
2294                    connector = 'postgres-cdc',
2295                    hostname = 'localhost',
2296                    port = '5432',
2297                    username = 'postgres',
2298                    password = '',
2299                    database.name = 'db'
2300                ) FORMAT PLAIN ENCODE JSON
2301                "#,
2302            )
2303            .await
2304            .unwrap();
2305        frontend.run_sql("CREATE USER cdc_user").await.unwrap();
2306        frontend
2307            .run_sql("GRANT CREATE ON SCHEMA public TO cdc_user")
2308            .await
2309            .unwrap();
2310
2311        let user_id = frontend
2312            .session_ref()
2313            .env()
2314            .user_info_reader()
2315            .read_guard()
2316            .get_user_by_name("cdc_user")
2317            .unwrap()
2318            .id;
2319        let user_session = frontend.session_user_ref(
2320            DEFAULT_DATABASE_NAME.to_owned(),
2321            "cdc_user".to_owned(),
2322            user_id,
2323        );
2324        let create_table =
2325            "CREATE TABLE cdc_table (id INT PRIMARY KEY) FROM cdc_source TABLE 'public.t'";
2326        let create_table_source = "CREATE SOURCE cdc_table_source (id INT PRIMARY KEY) \
2327             WITH (snapshot = 'false') FROM cdc_source TABLE 'public.t'";
2328
2329        for sql in [create_table, create_table_source] {
2330            let err = frontend
2331                .run_sql_with_session(user_session.clone(), sql)
2332                .await
2333                .unwrap_err();
2334            assert!(
2335                err.to_string()
2336                    .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2337                "{err:?}"
2338            );
2339        }
2340
2341        frontend
2342            .run_sql("GRANT SELECT ON SOURCE cdc_source TO cdc_user")
2343            .await
2344            .unwrap();
2345        frontend
2346            .run_sql_with_session(user_session.clone(), create_table)
2347            .await
2348            .unwrap();
2349        frontend
2350            .run_sql_with_session(user_session.clone(), create_table_source)
2351            .await
2352            .unwrap();
2353
2354        let table_source = {
2355            let catalog_reader = frontend.session_ref().env().catalog_reader().read_guard();
2356            catalog_reader
2357                .get_source_by_name(
2358                    DEFAULT_DATABASE_NAME,
2359                    SchemaPath::Name("public"),
2360                    "cdc_table_source",
2361                )
2362                .unwrap()
2363                .0
2364                .clone()
2365        };
2366        assert_eq!(table_source.with_properties.len(), 2);
2367        assert_eq!(
2368            table_source.with_properties.get_connector().as_deref(),
2369            Some("postgres-cdc")
2370        );
2371        assert_eq!(
2372            table_source
2373                .with_properties
2374                .get("snapshot")
2375                .map(String::as_str),
2376            Some("false")
2377        );
2378        assert!(table_source.with_properties.as_secret().is_empty());
2379        // The descriptor carries the resolved connect properties so that meta can validate it
2380        // against the upstream table; meta clears them before persisting the catalog, which is
2381        // covered by `test_create_cdc_table_source_dependency`.
2382        let external_table = table_source.info.external_table.as_ref().unwrap();
2383        assert!(!external_table.connect_properties.is_empty());
2384
2385        let err = frontend
2386            .run_sql_with_session(
2387                user_session.clone(),
2388                "SELECT * FROM postgres_query('cdc_table_source', 'SELECT 1')",
2389            )
2390            .await
2391            .unwrap_err();
2392        assert!(
2393            err.to_report_string()
2394                .contains("only accept shared CDC sources, not CDC table sources"),
2395            "{err:?}"
2396        );
2397
2398        frontend
2399            .run_sql("REVOKE SELECT ON SOURCE cdc_source FROM cdc_user")
2400            .await
2401            .unwrap();
2402        let err = frontend
2403            .run_sql_with_session(user_session, "ALTER TABLE cdc_table ADD COLUMN value INT")
2404            .await
2405            .unwrap_err();
2406        assert!(
2407            err.to_string()
2408                .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2409            "{err:?}"
2410        );
2411    }
2412
2413    #[tokio::test]
2414    async fn test_create_table_handler() {
2415        let sql =
2416            "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
2417        let frontend = LocalFrontend::new(Default::default()).await;
2418        frontend.run_sql(sql).await.unwrap();
2419
2420        let session = frontend.session_ref();
2421        let catalog_reader = session.env().catalog_reader().read_guard();
2422        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2423
2424        // Check table exists.
2425        let (table, _) = catalog_reader
2426            .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2427            .unwrap();
2428        assert_eq!(table.name(), "t");
2429
2430        let columns = table
2431            .columns
2432            .iter()
2433            .map(|col| (col.name(), col.data_type().clone()))
2434            .collect::<HashMap<&str, DataType>>();
2435
2436        let expected_columns = maplit::hashmap! {
2437            ROW_ID_COLUMN_NAME => DataType::Serial,
2438            "v1" => DataType::Int16,
2439            "v2" => StructType::new(
2440                vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
2441            )
2442            .with_ids([3, 4, 5].map(ColumnId::new))
2443            .into(),
2444            RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
2445        };
2446
2447        assert_eq!(columns, expected_columns, "{columns:#?}");
2448    }
2449
2450    #[tokio::test]
2451    async fn test_create_webhook_table_with_arbitrary_columns() {
2452        let frontend = LocalFrontend::new(Default::default()).await;
2453        frontend
2454            .run_sql("create schema ingest_schema;")
2455            .await
2456            .unwrap();
2457        frontend
2458            .run_sql(
2459                r#"
2460                create table ingest_schema.orders (
2461                    id int,
2462                    customer_name varchar,
2463                    amount double precision,
2464                    primary key (id)
2465                ) with (
2466                    connector = 'webhook'
2467                ) validate as secure_compare(
2468                    headers->>'x-rw-signature',
2469                    'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
2470                );
2471                "#,
2472            )
2473            .await
2474            .unwrap();
2475
2476        let session = frontend.session_ref();
2477        let catalog_reader = session.env().catalog_reader().read_guard();
2478        let (table, _) = catalog_reader
2479            .get_created_table_by_name(
2480                DEFAULT_DATABASE_NAME,
2481                SchemaPath::Name("ingest_schema"),
2482                "orders",
2483            )
2484            .unwrap();
2485
2486        assert!(table.webhook_info.is_some());
2487        assert_eq!(
2488            table
2489                .columns
2490                .iter()
2491                .filter(|column| column.can_dml())
2492                .count(),
2493            3
2494        );
2495    }
2496
2497    #[tokio::test]
2498    async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
2499        let frontend = LocalFrontend::new(Default::default()).await;
2500        frontend
2501            .run_sql(
2502                r#"
2503                create table webhook_single_column (
2504                    body jsonb
2505                ) with (
2506                    connector = 'webhook'
2507                ) validate as secure_compare(
2508                    headers->>'x-rw-signature',
2509                    'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
2510                );
2511                "#,
2512            )
2513            .await
2514            .unwrap();
2515    }
2516
2517    #[tokio::test]
2518    async fn test_create_webhook_table_with_generated_columns() {
2519        let frontend = LocalFrontend::new(Default::default()).await;
2520        let err = frontend
2521            .run_sql(
2522                r#"
2523                create table webhook_generated_columns (
2524                    id int,
2525                    amount double precision,
2526                    amount_with_fee double precision as amount + 1.0
2527                ) with (
2528                    connector = 'webhook'
2529                );
2530                "#,
2531            )
2532            .await
2533            .unwrap_err();
2534
2535        assert!(
2536            err.to_string()
2537                .contains("generated columns are not supported for webhook tables"),
2538            "{err:?}"
2539        );
2540    }
2541
2542    #[tokio::test]
2543    async fn test_create_webhook_table_with_default_value() {
2544        let frontend = LocalFrontend::new(Default::default()).await;
2545        let err = frontend
2546            .run_sql(
2547                r#"
2548                create table webhook_default_value (
2549                    id int default 42,
2550                    amount double precision
2551                ) with (
2552                    connector = 'webhook'
2553                );
2554                "#,
2555            )
2556            .await
2557            .unwrap_err();
2558
2559        assert!(
2560            err.to_string()
2561                .contains("default values are not supported for webhook tables"),
2562            "{err:?}"
2563        );
2564    }
2565
2566    #[tokio::test]
2567    async fn test_create_webhook_table_with_not_null_option() {
2568        let frontend = LocalFrontend::new(Default::default()).await;
2569        let err = frontend
2570            .run_sql(
2571                r#"
2572                create table webhook_not_null (
2573                    id int not null,
2574                    amount double precision
2575                ) with (
2576                    connector = 'webhook'
2577                );
2578                "#,
2579            )
2580            .await
2581            .unwrap_err();
2582
2583        assert!(
2584            err.to_string()
2585                .contains("only NULL column option is supported for webhook tables"),
2586            "{err:?}"
2587        );
2588    }
2589
2590    #[test]
2591    fn test_bind_primary_key() {
2592        // Note: Column ID 0 is reserved for row ID column.
2593
2594        for (sql, expected) in [
2595            ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
2596            ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
2597            ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
2598            (
2599                "create table t (v1 int primary key, v2 int primary key)",
2600                Err("multiple primary keys are not allowed"),
2601            ),
2602            (
2603                "create table t (v1 int primary key primary key, v2 int)",
2604                Err("multiple primary keys are not allowed"),
2605            ),
2606            (
2607                "create table t (v1 int, v2 int, primary key (v1))",
2608                Ok(&[1]),
2609            ),
2610            (
2611                "create table t (v1 int, primary key (v2), v2 int)",
2612                Ok(&[2]),
2613            ),
2614            (
2615                "create table t (primary key (v2, v1), v1 int, v2 int)",
2616                Ok(&[2, 1]),
2617            ),
2618            (
2619                "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
2620                Err("multiple primary keys are not allowed"),
2621            ),
2622            (
2623                "create table t (v1 int primary key, primary key (v1), v2 int)",
2624                Err("multiple primary keys are not allowed"),
2625            ),
2626            (
2627                "create table t (v1 int, primary key (V3), v2 int)",
2628                Err("column \"v3\" named in key does not exist"),
2629            ),
2630        ] {
2631            let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
2632            let risingwave_sqlparser::ast::Statement::CreateTable {
2633                columns: column_defs,
2634                constraints,
2635                ..
2636            } = ast.remove(0)
2637            else {
2638                panic!("test case should be create table")
2639            };
2640            let actual: Result<_> = (|| {
2641                let mut columns = bind_sql_columns(&column_defs, false)?;
2642                let mut col_id_gen = ColumnIdGenerator::new_initial();
2643                for c in &mut columns {
2644                    col_id_gen.generate(c)?;
2645                }
2646
2647                let pk_names =
2648                    bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
2649                let (_, pk_column_ids, _) =
2650                    bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
2651                Ok(pk_column_ids)
2652            })();
2653            match (expected, actual) {
2654                (Ok(expected), Ok(actual)) => assert_eq!(
2655                    expected.iter().copied().map(ColumnId::new).collect_vec(),
2656                    actual,
2657                    "sql: {sql}"
2658                ),
2659                (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
2660                (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
2661                (Err(expected), Err(actual)) => assert!(
2662                    actual.to_string().contains(expected),
2663                    "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
2664                ),
2665            }
2666        }
2667    }
2668
2669    #[tokio::test]
2670    async fn test_duplicate_props_options() {
2671        let proto_file = create_proto_file(PROTO_FILE_DATA);
2672        let sql = format!(
2673            r#"CREATE TABLE t
2674    WITH (
2675        connector = 'kinesis',
2676        aws.region='user_test_topic',
2677        endpoint='172.10.1.1:9090,172.10.1.2:9090',
2678        aws.credentials.access_key_id = 'your_access_key_1',
2679        aws.credentials.secret_access_key = 'your_secret_key_1'
2680    )
2681    FORMAT PLAIN ENCODE PROTOBUF (
2682        message = '.test.TestRecord',
2683        aws.credentials.access_key_id = 'your_access_key_2',
2684        aws.credentials.secret_access_key = 'your_secret_key_2',
2685        schema.location = 'file://{}',
2686    )"#,
2687            proto_file.path().to_str().unwrap()
2688        );
2689        let frontend = LocalFrontend::new(Default::default()).await;
2690        frontend.run_sql(sql).await.unwrap();
2691
2692        let session = frontend.session_ref();
2693        let catalog_reader = session.env().catalog_reader().read_guard();
2694        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2695
2696        // Check source exists.
2697        let (source, _) = catalog_reader
2698            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2699            .unwrap();
2700        assert_eq!(source.name, "t");
2701
2702        // AwsAuth params exist in options.
2703        assert_eq!(
2704            source
2705                .info
2706                .format_encode_options
2707                .get("aws.credentials.access_key_id")
2708                .unwrap(),
2709            "your_access_key_2"
2710        );
2711        assert_eq!(
2712            source
2713                .info
2714                .format_encode_options
2715                .get("aws.credentials.secret_access_key")
2716                .unwrap(),
2717            "your_secret_key_2"
2718        );
2719
2720        // AwsAuth params exist in props.
2721        assert_eq!(
2722            source
2723                .with_properties
2724                .get("aws.credentials.access_key_id")
2725                .unwrap(),
2726            "your_access_key_1"
2727        );
2728        assert_eq!(
2729            source
2730                .with_properties
2731                .get("aws.credentials.secret_access_key")
2732                .unwrap(),
2733            "your_secret_key_1"
2734        );
2735
2736        // Options are not merged into props.
2737        assert!(!source.with_properties.contains_key("schema.location"));
2738    }
2739}