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