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::{CdcSourceExternalTableIdentifier, build_cdc_table_id};
43use risingwave_connector::{
44    AUTO_SCHEMA_CHANGE_KEY, WithOptionsSecResolved, WithPropertiesExt, dispatch_cdc_source_type,
45};
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_connect_properties_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 canonical_name = dispatch_cdc_source_type!(
917        options.cdc_source_type(),
918        T,
919        T::canonical_name(&options),
920        unreachable!("CDC table without a CDC connector")
921    );
922    let cdc_table_desc = CdcTableDesc {
923        table_id,
924        source_id: source.id, // id of cdc source streaming job
925        external_table_name: external_table_name.clone(),
926        pk: table_pk,
927        pk_comparisons,
928        columns: non_generated_column_descs,
929        stream_key: pk_column_indices,
930        connect_properties: options,
931        secret_refs,
932    };
933
934    tracing::debug!(?cdc_table_desc, "create cdc table");
935    let options = build_cdc_scan_options_with_options(
936        context.with_options(),
937        cdc_table_desc.connect_properties.cdc_source_type(),
938    )?;
939
940    let logical_scan = LogicalCdcScan::create(
941        external_table_name,
942        Rc::new(cdc_table_desc),
943        context.clone(),
944        options,
945    );
946
947    let scan_node: LogicalPlanRef = logical_scan.into();
948    let required_cols = FixedBitSet::with_capacity(non_generated_column_num);
949    let plan_root = PlanRoot::new_with_logical_plan(
950        scan_node,
951        RequiredDist::Any,
952        Order::any(),
953        required_cols,
954        vec![],
955    );
956
957    let cdc_table_id = build_cdc_table_id(source.id, &canonical_name);
958    let materialize = plan_root.gen_table_plan(
959        context,
960        resolved_table_name,
961        database_id,
962        schema_id,
963        CreateTableInfo {
964            columns,
965            pk_column_ids,
966            row_id_index: None,
967            watermark_descs,
968            source_catalog: Some((*source).clone()),
969            version: col_id_gen.into_version(),
970        },
971        CreateTableProps {
972            definition,
973            append_only: false,
974            on_conflict: on_conflict.into(),
975            with_version_columns,
976            webhook_info: None,
977            engine,
978        },
979    )?;
980
981    let mut table = materialize.table().clone();
982    table.owner = session.user_id();
983    table.cdc_table_id = Some(cdc_table_id);
984    table.cdc_table_type = Some(cdc_table_type);
985    Ok((
986        ensure_sync_log_store_fragment_root(materialize.into()),
987        table,
988    ))
989}
990
991#[allow(clippy::too_many_arguments)]
992pub(super) async fn handle_create_table_plan(
993    handler_args: HandlerArgs,
994    explain_options: ExplainOptions,
995    format_encode: Option<FormatEncodeOptions>,
996    cdc_table_info: Option<CdcTableInfo>,
997    table_name: &ObjectName,
998    column_defs: Vec<ColumnDef>,
999    wildcard_idx: Option<usize>,
1000    constraints: Vec<TableConstraint>,
1001    source_watermarks: Vec<SourceWatermark>,
1002    append_only: bool,
1003    on_conflict: Option<OnConflict>,
1004    with_version_columns: Vec<String>,
1005    include_column_options: IncludeOption,
1006    webhook_info: Option<WebhookSourceInfo>,
1007    engine: Engine,
1008) -> Result<(
1009    PlanRef,
1010    Option<SourceCatalog>,
1011    TableCatalog,
1012    TableJobType,
1013    Option<SourceId>,
1014)> {
1015    let col_id_gen = ColumnIdGenerator::new_initial();
1016    let format_encode = check_create_table_with_source(
1017        &handler_args.with_options,
1018        format_encode,
1019        &include_column_options,
1020        &cdc_table_info,
1021    )?;
1022    let webhook_info = webhook_info
1023        .map(|info| bind_webhook_info(&handler_args.session, &column_defs, info))
1024        .transpose()?;
1025
1026    let props = CreateTableProps {
1027        definition: handler_args.normalized_sql.clone(),
1028        append_only,
1029        on_conflict: on_conflict.into(),
1030        with_version_columns: with_version_columns.clone(),
1031        webhook_info,
1032        engine,
1033    };
1034
1035    let ((plan, source, table), job_type, shared_shource_id) = match (
1036        format_encode,
1037        cdc_table_info.as_ref(),
1038    ) {
1039        (Some(format_encode), None) => (
1040            gen_create_table_plan_with_source(
1041                handler_args,
1042                explain_options,
1043                table_name.clone(),
1044                column_defs,
1045                wildcard_idx,
1046                constraints,
1047                format_encode,
1048                source_watermarks,
1049                col_id_gen,
1050                include_column_options,
1051                props,
1052                SqlColumnStrategy::FollowChecked,
1053            )
1054            .await?,
1055            TableJobType::General,
1056            None,
1057        ),
1058        (None, None) => {
1059            let context = OptimizerContext::new(handler_args, explain_options);
1060            let (plan, table) = gen_create_table_plan(
1061                context,
1062                table_name.clone(),
1063                column_defs,
1064                constraints,
1065                col_id_gen,
1066                source_watermarks,
1067                props,
1068                false,
1069            )?;
1070
1071            ((plan, None, table), TableJobType::General, None)
1072        }
1073
1074        (None, Some(cdc_table)) => {
1075            sanity_check_for_table_on_cdc_source(
1076                append_only,
1077                &column_defs,
1078                &wildcard_idx,
1079                &constraints,
1080                &source_watermarks,
1081            )?;
1082
1083            generated_columns_check_for_cdc_table(&column_defs)?;
1084            not_null_check_for_cdc_table(&wildcard_idx, &column_defs)?;
1085
1086            let session = &handler_args.session;
1087            let db_name = &session.database();
1088            let user_name = &session.user_name();
1089            let search_path = session.config().search_path();
1090            let (schema_name, resolved_table_name) =
1091                Binder::resolve_schema_qualified_name(db_name, table_name)?;
1092            let (database_id, schema_id) =
1093                session.get_database_and_schema_id_for_create(schema_name.clone())?;
1094
1095            // cdc table cannot be append-only
1096            let (source_schema, source_name) =
1097                Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
1098
1099            let source = {
1100                let catalog_reader = session.env().catalog_reader().read_guard();
1101                let schema_path =
1102                    SchemaPath::new(source_schema.as_deref(), &search_path, user_name);
1103
1104                let (source, _) = catalog_reader.get_source_by_name(
1105                    db_name,
1106                    schema_path,
1107                    source_name.as_str(),
1108                )?;
1109                source.clone()
1110            };
1111            check_cdc_source_select_privilege(session, &source)?;
1112            let (cdc_with_options, external_table_name) = derive_connect_properties_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                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, external_table_name) = derive_connect_properties_for_cdc_table(
1964                &source.with_properties,
1965                cdc_table.external_table_name.clone(),
1966            )?;
1967
1968            let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
1969            let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1970                cdc_with_options.clone(),
1971                &pk_names,
1972            ))
1973            .await?;
1974
1975            // CDC-table branch only: see the comment at the symmetric call site in
1976            // `handle_create_table_plan`. Plain (non-CDC) tables don't hit this check.
1977            reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1978
1979            let context: OptimizerContextRef =
1980                OptimizerContext::new(handler_args, ExplainOptions::default()).into();
1981            let (plan, table) = gen_create_table_plan_for_cdc_table(
1982                context,
1983                source,
1984                external_table_name,
1985                columns,
1986                source_watermarks,
1987                column_catalogs,
1988                pk_names,
1989                pk_comparisons,
1990                cdc_with_options,
1991                col_id_gen,
1992                on_conflict,
1993                with_version_columns
1994                    .iter()
1995                    .map(|col| col.real_value())
1996                    .collect(),
1997                include_column_options,
1998                table_name,
1999                resolved_table_name,
2000                original_catalog.database_id,
2001                original_catalog.schema_id,
2002                original_catalog.id(),
2003                engine,
2004            )?;
2005
2006            ((plan, None, table), TableJobType::SharedCdcSource)
2007        }
2008        (Some(_), Some(_)) => {
2009            return Err(ErrorCode::NotSupported(
2010                "Data format and encoding format doesn't apply to table created from a CDC source"
2011                    .into(),
2012                "Remove the FORMAT and ENCODE specification".into(),
2013            )
2014            .into());
2015        }
2016    };
2017
2018    if table.pk_column_ids() != original_catalog.pk_column_ids() {
2019        Err(ErrorCode::InvalidInputSyntax(
2020            "alter primary key of table is not supported".to_owned(),
2021        ))?
2022    }
2023
2024    let graph = build_graph(plan, Some(GraphJobType::Table))?;
2025
2026    // Fill the original table ID.
2027    table.id = original_catalog.id();
2028    if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2029        table.associated_source_id = Some(source_id);
2030
2031        let source = source.as_mut().unwrap();
2032        source.id = source_id;
2033        source.associated_table_id = Some(table.id());
2034    }
2035
2036    Ok((graph, table, source, job_type))
2037}
2038
2039fn get_source_and_resolved_table_name(
2040    session: &Arc<SessionImpl>,
2041    cdc_table: CdcTableInfo,
2042    table_name: ObjectName,
2043) -> Result<(Arc<SourceCatalog>, String)> {
2044    let db_name = &session.database();
2045    let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2046
2047    let (source_schema, source_name) =
2048        Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2049
2050    let source = {
2051        let catalog_reader = session.env().catalog_reader().read_guard();
2052        let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2053        let (source, _) = catalog_reader.get_source_by_name(
2054            db_name,
2055            SchemaPath::Name(schema_name.as_str()),
2056            source_name.as_str(),
2057        )?;
2058        source.clone()
2059    };
2060    check_cdc_source_select_privilege(session, &source)?;
2061
2062    Ok((source, resolved_table_name))
2063}
2064
2065pub(crate) fn check_cdc_source_select_privilege(
2066    session: &SessionImpl,
2067    source: &SourceCatalog,
2068) -> Result<()> {
2069    session.check_privileges(&[ObjectCheckItem::new(
2070        source.owner,
2071        AclMode::Select,
2072        source.name.clone(),
2073        source.id,
2074    )])
2075}
2076
2077// validate the webhook_info and also bind the webhook_info to protobuf
2078fn bind_webhook_info(
2079    session: &Arc<SessionImpl>,
2080    column_defs: &[ColumnDef],
2081    webhook_info: WebhookSourceInfo,
2082) -> Result<PbWebhookSourceInfo> {
2083    let WebhookSourceInfo {
2084        secret_ref,
2085        signature_expr,
2086        wait_for_persistence,
2087        is_batched,
2088    } = webhook_info;
2089
2090    for column in column_defs {
2091        for option_def in &column.options {
2092            match option_def.option {
2093                ColumnOption::Null => {}
2094                ColumnOption::GeneratedColumns(_) => {
2095                    return Err(ErrorCode::InvalidInputSyntax(
2096                        "generated columns are not supported for webhook tables".to_owned(),
2097                    )
2098                    .into());
2099                }
2100                ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2101                    return Err(ErrorCode::InvalidInputSyntax(
2102                        "default values are not supported for webhook tables".to_owned(),
2103                    )
2104                    .into());
2105                }
2106                ColumnOption::NotNull
2107                | ColumnOption::Unique { .. }
2108                | ColumnOption::ForeignKey { .. }
2109                | ColumnOption::Check(_)
2110                | ColumnOption::DialectSpecific(_) => {
2111                    return Err(ErrorCode::InvalidInputSyntax(
2112                        "only NULL column option is supported for webhook tables".to_owned(),
2113                    )
2114                    .into());
2115                }
2116            }
2117        }
2118    }
2119
2120    // validate secret_ref
2121    let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2122        let db_name = &session.database();
2123        let (schema_name, secret_name) =
2124            Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2125        let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2126        (
2127            Some(PbSecretRef {
2128                secret_id: secret_catalog.id,
2129                ref_as: match secret_ref.ref_as {
2130                    SecretRefAsType::Text => PbRefAsType::Text,
2131                    SecretRefAsType::File => PbRefAsType::File,
2132                }
2133                .into(),
2134            }),
2135            Some(secret_name),
2136        )
2137    } else {
2138        (None, None)
2139    };
2140
2141    let signature_expr = if let Some(signature_expr) = signature_expr {
2142        let payload_name = if column_defs.len() == 1
2143            && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
2144        {
2145            column_defs[0].name.real_value()
2146        } else {
2147            WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
2148        };
2149        let secure_compare_context = SecureCompareContext {
2150            payload_name,
2151            secret_name,
2152        };
2153        let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
2154        let expr = binder.bind_expr(&signature_expr)?;
2155
2156        // validate expr, ensuring it is SECURE_COMPARE()
2157        if expr.as_function_call().is_none()
2158            || expr.as_function_call().unwrap().func_type()
2159                != crate::optimizer::plan_node::generic::ExprType::SecureCompare
2160        {
2161            return Err(ErrorCode::InvalidInputSyntax(
2162                "The signature verification function must be SECURE_COMPARE()".to_owned(),
2163            )
2164            .into());
2165        }
2166
2167        Some(expr.to_expr_proto())
2168    } else {
2169        session.notice_to_user(
2170            "VALIDATE clause is strongly recommended for safety or production usages",
2171        );
2172        None
2173    };
2174
2175    let pb_webhook_info = PbWebhookSourceInfo {
2176        secret_ref: pb_secret_ref,
2177        signature_expr,
2178        wait_for_persistence,
2179        is_batched,
2180    };
2181
2182    Ok(pb_webhook_info)
2183}
2184
2185#[cfg(test)]
2186mod tests {
2187    use risingwave_common::catalog::{
2188        DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
2189    };
2190    use risingwave_common::types::StructType;
2191
2192    use super::*;
2193    use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
2194
2195    #[tokio::test]
2196    async fn test_iceberg_engine_primary_key_types() {
2197        let frontend = LocalFrontend::new(Default::default()).await;
2198        for (primary_key, append_only, pk_index, supported) in [
2199            ("id, period", false, false, false),
2200            ("id, items", false, false, false),
2201            ("id, mapping", false, false, false),
2202            ("id, f", false, false, false),
2203            ("id, d", false, false, false),
2204            ("id", false, false, true),
2205            ("id, ts", false, false, true),
2206            ("", false, false, true),
2207            ("id, period", true, false, true),
2208            ("id, period", false, true, true),
2209        ] {
2210            let pk_clause = if primary_key.is_empty() {
2211                String::new()
2212            } else {
2213                format!(", PRIMARY KEY ({primary_key})")
2214            };
2215            frontend
2216                .run_sql(&format!(
2217                    "CREATE TABLE t (id INT, period STRUCT<start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ>, \
2218                     items INT[], mapping MAP(INT, INT), f REAL, d DOUBLE PRECISION, ts TIMESTAMPTZ \
2219                     {pk_clause}) {}",
2220                    if append_only { "APPEND ONLY" } else { "" }
2221                ))
2222                .await
2223                .unwrap();
2224            let session = frontend.session_ref();
2225            let table = session
2226                .env()
2227                .catalog_reader()
2228                .read_guard()
2229                .get_created_table_by_name(
2230                    DEFAULT_DATABASE_NAME,
2231                    SchemaPath::Name(DEFAULT_SCHEMA_NAME),
2232                    "t",
2233                )
2234                .unwrap()
2235                .0
2236                .clone();
2237            let pks = table
2238                .pk_column_names()
2239                .into_iter()
2240                .map(str::to_owned)
2241                .collect_vec();
2242            for write_mode in ["merge-on-read", "copy-on-write"] {
2243                if write_mode == "copy-on-write" && (append_only || pk_index) {
2244                    continue;
2245                }
2246                let options = BTreeMap::from([
2247                    ("connector".to_owned(), "iceberg".to_owned()),
2248                    ("catalog.type".to_owned(), "storage".to_owned()),
2249                    (
2250                        "warehouse.path".to_owned(),
2251                        "s3://test/warehouse".to_owned(),
2252                    ),
2253                    ("database.name".to_owned(), "public".to_owned()),
2254                    ("table.name".to_owned(), "t".to_owned()),
2255                    ("write_mode".to_owned(), write_mode.to_owned()),
2256                    ("enable_pk_index".to_owned(), pk_index.to_string()),
2257                ]);
2258                let result = build_iceberg_engine_sink_options(
2259                    options,
2260                    &WithOptions::default(),
2261                    &table,
2262                    &pks,
2263                );
2264                if supported {
2265                    result.unwrap();
2266                } else {
2267                    let err = result.unwrap_err().to_report_string();
2268                    let column = primary_key.split(", ").last().unwrap();
2269                    assert!(
2270                        err.contains(&format!(
2271                            "Iceberg engine table primary key column \"{column}\" has unsupported type"
2272                        )),
2273                        "{write_mode}: {err}"
2274                    );
2275                }
2276            }
2277            frontend.run_sql("DROP TABLE t").await.unwrap();
2278        }
2279    }
2280
2281    #[tokio::test]
2282    async fn test_cdc_relations_require_select_privilege_on_source() {
2283        let frontend = LocalFrontend::new(Default::default()).await;
2284        // PostgreSQL CDC with an explicit schema avoids querying upstream PK metadata,
2285        // so this privilege test does not require an external database.
2286        frontend
2287            .run_sql(
2288                r#"
2289                CREATE SOURCE cdc_source WITH (
2290                    connector = 'postgres-cdc',
2291                    hostname = 'localhost',
2292                    port = '5432',
2293                    username = 'postgres',
2294                    password = '',
2295                    database.name = 'db'
2296                ) FORMAT PLAIN ENCODE JSON
2297                "#,
2298            )
2299            .await
2300            .unwrap();
2301        frontend.run_sql("CREATE USER cdc_user").await.unwrap();
2302        frontend
2303            .run_sql("GRANT CREATE ON SCHEMA public TO cdc_user")
2304            .await
2305            .unwrap();
2306
2307        let user_id = frontend
2308            .session_ref()
2309            .env()
2310            .user_info_reader()
2311            .read_guard()
2312            .get_user_by_name("cdc_user")
2313            .unwrap()
2314            .id;
2315        let user_session = frontend.session_user_ref(
2316            DEFAULT_DATABASE_NAME.to_owned(),
2317            "cdc_user".to_owned(),
2318            user_id,
2319        );
2320        let create_table =
2321            "CREATE TABLE cdc_table (id INT PRIMARY KEY) FROM cdc_source TABLE 'public.t'";
2322        let create_table_source = "CREATE SOURCE cdc_table_source (id INT PRIMARY KEY) \
2323             WITH (snapshot = 'false') FROM cdc_source TABLE 'public.t'";
2324
2325        for sql in [create_table, create_table_source] {
2326            let err = frontend
2327                .run_sql_with_session(user_session.clone(), sql)
2328                .await
2329                .unwrap_err();
2330            assert!(
2331                err.to_string()
2332                    .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2333                "{err:?}"
2334            );
2335        }
2336
2337        frontend
2338            .run_sql("GRANT SELECT ON SOURCE cdc_source TO cdc_user")
2339            .await
2340            .unwrap();
2341        frontend
2342            .run_sql_with_session(user_session.clone(), create_table)
2343            .await
2344            .unwrap();
2345        frontend
2346            .run_sql_with_session(user_session.clone(), create_table_source)
2347            .await
2348            .unwrap();
2349
2350        let table_source = {
2351            let catalog_reader = frontend.session_ref().env().catalog_reader().read_guard();
2352            catalog_reader
2353                .get_source_by_name(
2354                    DEFAULT_DATABASE_NAME,
2355                    SchemaPath::Name("public"),
2356                    "cdc_table_source",
2357                )
2358                .unwrap()
2359                .0
2360                .clone()
2361        };
2362        assert_eq!(table_source.with_properties.len(), 2);
2363        assert_eq!(
2364            table_source.with_properties.get_connector().as_deref(),
2365            Some("postgres-cdc")
2366        );
2367        assert_eq!(
2368            table_source
2369                .with_properties
2370                .get("snapshot")
2371                .map(String::as_str),
2372            Some("false")
2373        );
2374        assert!(table_source.with_properties.as_secret().is_empty());
2375        // The descriptor carries the resolved connect properties so that meta can validate it
2376        // against the upstream table; meta clears them before persisting the catalog, which is
2377        // covered by `test_create_cdc_table_source_dependency`.
2378        let external_table = table_source.info.external_table.as_ref().unwrap();
2379        assert!(!external_table.connect_properties.is_empty());
2380
2381        let err = frontend
2382            .run_sql_with_session(
2383                user_session.clone(),
2384                "SELECT * FROM postgres_query('cdc_table_source', 'SELECT 1')",
2385            )
2386            .await
2387            .unwrap_err();
2388        assert!(
2389            err.to_report_string()
2390                .contains("only accept shared CDC sources, not CDC table sources"),
2391            "{err:?}"
2392        );
2393
2394        frontend
2395            .run_sql("REVOKE SELECT ON SOURCE cdc_source FROM cdc_user")
2396            .await
2397            .unwrap();
2398        let err = frontend
2399            .run_sql_with_session(user_session, "ALTER TABLE cdc_table ADD COLUMN value INT")
2400            .await
2401            .unwrap_err();
2402        assert!(
2403            err.to_string()
2404                .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2405            "{err:?}"
2406        );
2407    }
2408
2409    #[tokio::test]
2410    async fn test_create_table_handler() {
2411        let sql =
2412            "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
2413        let frontend = LocalFrontend::new(Default::default()).await;
2414        frontend.run_sql(sql).await.unwrap();
2415
2416        let session = frontend.session_ref();
2417        let catalog_reader = session.env().catalog_reader().read_guard();
2418        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2419
2420        // Check table exists.
2421        let (table, _) = catalog_reader
2422            .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2423            .unwrap();
2424        assert_eq!(table.name(), "t");
2425
2426        let columns = table
2427            .columns
2428            .iter()
2429            .map(|col| (col.name(), col.data_type().clone()))
2430            .collect::<HashMap<&str, DataType>>();
2431
2432        let expected_columns = maplit::hashmap! {
2433            ROW_ID_COLUMN_NAME => DataType::Serial,
2434            "v1" => DataType::Int16,
2435            "v2" => StructType::new(
2436                vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
2437            )
2438            .with_ids([3, 4, 5].map(ColumnId::new))
2439            .into(),
2440            RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
2441        };
2442
2443        assert_eq!(columns, expected_columns, "{columns:#?}");
2444    }
2445
2446    #[tokio::test]
2447    async fn test_create_webhook_table_with_arbitrary_columns() {
2448        let frontend = LocalFrontend::new(Default::default()).await;
2449        frontend
2450            .run_sql("create schema ingest_schema;")
2451            .await
2452            .unwrap();
2453        frontend
2454            .run_sql(
2455                r#"
2456                create table ingest_schema.orders (
2457                    id int,
2458                    customer_name varchar,
2459                    amount double precision,
2460                    primary key (id)
2461                ) with (
2462                    connector = 'webhook'
2463                ) validate as secure_compare(
2464                    headers->>'x-rw-signature',
2465                    'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
2466                );
2467                "#,
2468            )
2469            .await
2470            .unwrap();
2471
2472        let session = frontend.session_ref();
2473        let catalog_reader = session.env().catalog_reader().read_guard();
2474        let (table, _) = catalog_reader
2475            .get_created_table_by_name(
2476                DEFAULT_DATABASE_NAME,
2477                SchemaPath::Name("ingest_schema"),
2478                "orders",
2479            )
2480            .unwrap();
2481
2482        assert!(table.webhook_info.is_some());
2483        assert_eq!(
2484            table
2485                .columns
2486                .iter()
2487                .filter(|column| column.can_dml())
2488                .count(),
2489            3
2490        );
2491    }
2492
2493    #[tokio::test]
2494    async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
2495        let frontend = LocalFrontend::new(Default::default()).await;
2496        frontend
2497            .run_sql(
2498                r#"
2499                create table webhook_single_column (
2500                    body jsonb
2501                ) with (
2502                    connector = 'webhook'
2503                ) validate as secure_compare(
2504                    headers->>'x-rw-signature',
2505                    'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
2506                );
2507                "#,
2508            )
2509            .await
2510            .unwrap();
2511    }
2512
2513    #[tokio::test]
2514    async fn test_create_webhook_table_with_generated_columns() {
2515        let frontend = LocalFrontend::new(Default::default()).await;
2516        let err = frontend
2517            .run_sql(
2518                r#"
2519                create table webhook_generated_columns (
2520                    id int,
2521                    amount double precision,
2522                    amount_with_fee double precision as amount + 1.0
2523                ) with (
2524                    connector = 'webhook'
2525                );
2526                "#,
2527            )
2528            .await
2529            .unwrap_err();
2530
2531        assert!(
2532            err.to_string()
2533                .contains("generated columns are not supported for webhook tables"),
2534            "{err:?}"
2535        );
2536    }
2537
2538    #[tokio::test]
2539    async fn test_create_webhook_table_with_default_value() {
2540        let frontend = LocalFrontend::new(Default::default()).await;
2541        let err = frontend
2542            .run_sql(
2543                r#"
2544                create table webhook_default_value (
2545                    id int default 42,
2546                    amount double precision
2547                ) with (
2548                    connector = 'webhook'
2549                );
2550                "#,
2551            )
2552            .await
2553            .unwrap_err();
2554
2555        assert!(
2556            err.to_string()
2557                .contains("default values are not supported for webhook tables"),
2558            "{err:?}"
2559        );
2560    }
2561
2562    #[tokio::test]
2563    async fn test_create_webhook_table_with_not_null_option() {
2564        let frontend = LocalFrontend::new(Default::default()).await;
2565        let err = frontend
2566            .run_sql(
2567                r#"
2568                create table webhook_not_null (
2569                    id int not null,
2570                    amount double precision
2571                ) with (
2572                    connector = 'webhook'
2573                );
2574                "#,
2575            )
2576            .await
2577            .unwrap_err();
2578
2579        assert!(
2580            err.to_string()
2581                .contains("only NULL column option is supported for webhook tables"),
2582            "{err:?}"
2583        );
2584    }
2585
2586    #[test]
2587    fn test_bind_primary_key() {
2588        // Note: Column ID 0 is reserved for row ID column.
2589
2590        for (sql, expected) in [
2591            ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
2592            ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
2593            ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
2594            (
2595                "create table t (v1 int primary key, v2 int primary key)",
2596                Err("multiple primary keys are not allowed"),
2597            ),
2598            (
2599                "create table t (v1 int primary key primary key, v2 int)",
2600                Err("multiple primary keys are not allowed"),
2601            ),
2602            (
2603                "create table t (v1 int, v2 int, primary key (v1))",
2604                Ok(&[1]),
2605            ),
2606            (
2607                "create table t (v1 int, primary key (v2), v2 int)",
2608                Ok(&[2]),
2609            ),
2610            (
2611                "create table t (primary key (v2, v1), v1 int, v2 int)",
2612                Ok(&[2, 1]),
2613            ),
2614            (
2615                "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
2616                Err("multiple primary keys are not allowed"),
2617            ),
2618            (
2619                "create table t (v1 int primary key, primary key (v1), v2 int)",
2620                Err("multiple primary keys are not allowed"),
2621            ),
2622            (
2623                "create table t (v1 int, primary key (V3), v2 int)",
2624                Err("column \"v3\" named in key does not exist"),
2625            ),
2626        ] {
2627            let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
2628            let risingwave_sqlparser::ast::Statement::CreateTable {
2629                columns: column_defs,
2630                constraints,
2631                ..
2632            } = ast.remove(0)
2633            else {
2634                panic!("test case should be create table")
2635            };
2636            let actual: Result<_> = (|| {
2637                let mut columns = bind_sql_columns(&column_defs, false)?;
2638                let mut col_id_gen = ColumnIdGenerator::new_initial();
2639                for c in &mut columns {
2640                    col_id_gen.generate(c)?;
2641                }
2642
2643                let pk_names =
2644                    bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
2645                let (_, pk_column_ids, _) =
2646                    bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
2647                Ok(pk_column_ids)
2648            })();
2649            match (expected, actual) {
2650                (Ok(expected), Ok(actual)) => assert_eq!(
2651                    expected.iter().copied().map(ColumnId::new).collect_vec(),
2652                    actual,
2653                    "sql: {sql}"
2654                ),
2655                (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
2656                (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
2657                (Err(expected), Err(actual)) => assert!(
2658                    actual.to_string().contains(expected),
2659                    "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
2660                ),
2661            }
2662        }
2663    }
2664
2665    #[tokio::test]
2666    async fn test_duplicate_props_options() {
2667        let proto_file = create_proto_file(PROTO_FILE_DATA);
2668        let sql = format!(
2669            r#"CREATE TABLE t
2670    WITH (
2671        connector = 'kinesis',
2672        aws.region='user_test_topic',
2673        endpoint='172.10.1.1:9090,172.10.1.2:9090',
2674        aws.credentials.access_key_id = 'your_access_key_1',
2675        aws.credentials.secret_access_key = 'your_secret_key_1'
2676    )
2677    FORMAT PLAIN ENCODE PROTOBUF (
2678        message = '.test.TestRecord',
2679        aws.credentials.access_key_id = 'your_access_key_2',
2680        aws.credentials.secret_access_key = 'your_secret_key_2',
2681        schema.location = 'file://{}',
2682    )"#,
2683            proto_file.path().to_str().unwrap()
2684        );
2685        let frontend = LocalFrontend::new(Default::default()).await;
2686        frontend.run_sql(sql).await.unwrap();
2687
2688        let session = frontend.session_ref();
2689        let catalog_reader = session.env().catalog_reader().read_guard();
2690        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2691
2692        // Check source exists.
2693        let (source, _) = catalog_reader
2694            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2695            .unwrap();
2696        assert_eq!(source.name, "t");
2697
2698        // AwsAuth params exist in options.
2699        assert_eq!(
2700            source
2701                .info
2702                .format_encode_options
2703                .get("aws.credentials.access_key_id")
2704                .unwrap(),
2705            "your_access_key_2"
2706        );
2707        assert_eq!(
2708            source
2709                .info
2710                .format_encode_options
2711                .get("aws.credentials.secret_access_key")
2712                .unwrap(),
2713            "your_secret_key_2"
2714        );
2715
2716        // AwsAuth params exist in props.
2717        assert_eq!(
2718            source
2719                .with_properties
2720                .get("aws.credentials.access_key_id")
2721                .unwrap(),
2722            "your_access_key_1"
2723        );
2724        assert_eq!(
2725            source
2726                .with_properties
2727                .get("aws.credentials.secret_access_key")
2728                .unwrap(),
2729            "your_secret_key_1"
2730        );
2731
2732        // Options are not merged into props.
2733        assert!(!source.with_properties.contains_key("schema.location"));
2734    }
2735}