Skip to main content

risingwave_frontend/handler/
create_table.rs

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