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            include_column_options.is_empty()
1896                && with_options
1897                    .iter()
1898                    .all(|opt| opt.name.real_value().to_lowercase() != "connector")
1899        );
1900    }
1901
1902    let props = CreateTableProps {
1903        definition: handler_args.normalized_sql.clone(),
1904        append_only,
1905        on_conflict: on_conflict.into(),
1906        with_version_columns: with_version_columns
1907            .iter()
1908            .map(|col| col.real_value())
1909            .collect(),
1910        webhook_info: original_catalog.webhook_info.clone(),
1911        engine,
1912    };
1913
1914    let ((plan, mut source, mut table), job_type) = match (format_encode, cdc_table_info.as_ref()) {
1915        (Some(format_encode), None) => (
1916            gen_create_table_plan_with_source(
1917                handler_args,
1918                ExplainOptions::default(),
1919                table_name,
1920                columns,
1921                wildcard_idx,
1922                constraints,
1923                format_encode,
1924                source_watermarks,
1925                col_id_gen,
1926                include_column_options,
1927                props,
1928                sql_column_strategy,
1929            )
1930            .await?,
1931            TableJobType::General,
1932        ),
1933        (None, None) => {
1934            let context = OptimizerContext::from_handler_args(handler_args);
1935            let (plan, table) = gen_create_table_plan(
1936                context,
1937                table_name,
1938                columns,
1939                constraints,
1940                col_id_gen,
1941                source_watermarks,
1942                props,
1943                true,
1944            )?;
1945            ((plan, None, table), TableJobType::General)
1946        }
1947        (None, Some(cdc_table)) => {
1948            sanity_check_for_table_on_cdc_source(
1949                append_only,
1950                &columns,
1951                &wildcard_idx,
1952                &constraints,
1953                &source_watermarks,
1954            )?;
1955
1956            let session = &handler_args.session;
1957            let (source, resolved_table_name) =
1958                get_source_and_resolved_table_name(session, cdc_table.clone(), table_name.clone())?;
1959
1960            let (cdc_with_options, normalized_external_table_name) =
1961                derive_with_options_for_cdc_table(
1962                    &source.with_properties,
1963                    cdc_table.external_table_name.clone(),
1964                )?;
1965
1966            let (column_catalogs, pk_names) = bind_cdc_table_schema(&columns, &constraints, true)?;
1967            let pk_comparisons = Box::pin(bind_cdc_pk_comparisons_externally(
1968                cdc_with_options.clone(),
1969                &pk_names,
1970            ))
1971            .await?;
1972
1973            // CDC-table branch only: see the comment at the symmetric call site in
1974            // `handle_create_table_plan`. Plain (non-CDC) tables don't hit this check.
1975            reject_pk_filtered_by_debezium_column_filter(&pk_names, &cdc_with_options)?;
1976
1977            let context: OptimizerContextRef =
1978                OptimizerContext::new(handler_args, ExplainOptions::default()).into();
1979            let (plan, table) = gen_create_table_plan_for_cdc_table(
1980                context,
1981                source,
1982                normalized_external_table_name,
1983                columns,
1984                source_watermarks,
1985                column_catalogs,
1986                pk_names,
1987                pk_comparisons,
1988                cdc_with_options,
1989                col_id_gen,
1990                on_conflict,
1991                with_version_columns
1992                    .iter()
1993                    .map(|col| col.real_value())
1994                    .collect(),
1995                include_column_options,
1996                table_name,
1997                resolved_table_name,
1998                original_catalog.database_id,
1999                original_catalog.schema_id,
2000                original_catalog.id(),
2001                engine,
2002            )?;
2003
2004            ((plan, None, table), TableJobType::SharedCdcSource)
2005        }
2006        (Some(_), Some(_)) => {
2007            return Err(ErrorCode::NotSupported(
2008                "Data format and encoding format doesn't apply to table created from a CDC source"
2009                    .into(),
2010                "Remove the FORMAT and ENCODE specification".into(),
2011            )
2012            .into());
2013        }
2014    };
2015
2016    if table.pk_column_ids() != original_catalog.pk_column_ids() {
2017        Err(ErrorCode::InvalidInputSyntax(
2018            "alter primary key of table is not supported".to_owned(),
2019        ))?
2020    }
2021
2022    let graph = build_graph(plan, Some(GraphJobType::Table))?;
2023
2024    // Fill the original table ID.
2025    table.id = original_catalog.id();
2026    if !is_drop_connector && let Some(source_id) = original_catalog.associated_source_id() {
2027        table.associated_source_id = Some(source_id);
2028
2029        let source = source.as_mut().unwrap();
2030        source.id = source_id;
2031        source.associated_table_id = Some(table.id());
2032    }
2033
2034    Ok((graph, table, source, job_type))
2035}
2036
2037fn get_source_and_resolved_table_name(
2038    session: &Arc<SessionImpl>,
2039    cdc_table: CdcTableInfo,
2040    table_name: ObjectName,
2041) -> Result<(Arc<SourceCatalog>, String)> {
2042    let db_name = &session.database();
2043    let (_, resolved_table_name) = Binder::resolve_schema_qualified_name(db_name, &table_name)?;
2044
2045    let (source_schema, source_name) =
2046        Binder::resolve_schema_qualified_name(db_name, &cdc_table.source_name)?;
2047
2048    let source = {
2049        let catalog_reader = session.env().catalog_reader().read_guard();
2050        let schema_name = source_schema.unwrap_or(DEFAULT_SCHEMA_NAME.to_owned());
2051        let (source, _) = catalog_reader.get_source_by_name(
2052            db_name,
2053            SchemaPath::Name(schema_name.as_str()),
2054            source_name.as_str(),
2055        )?;
2056        source.clone()
2057    };
2058    check_cdc_source_select_privilege(session, &source)?;
2059
2060    Ok((source, resolved_table_name))
2061}
2062
2063pub(crate) fn check_cdc_source_select_privilege(
2064    session: &SessionImpl,
2065    source: &SourceCatalog,
2066) -> Result<()> {
2067    session.check_privileges(&[ObjectCheckItem::new(
2068        source.owner,
2069        AclMode::Select,
2070        source.name.clone(),
2071        source.id,
2072    )])
2073}
2074
2075// validate the webhook_info and also bind the webhook_info to protobuf
2076fn bind_webhook_info(
2077    session: &Arc<SessionImpl>,
2078    column_defs: &[ColumnDef],
2079    webhook_info: WebhookSourceInfo,
2080) -> Result<PbWebhookSourceInfo> {
2081    let WebhookSourceInfo {
2082        secret_ref,
2083        signature_expr,
2084        wait_for_persistence,
2085        is_batched,
2086    } = webhook_info;
2087
2088    for column in column_defs {
2089        for option_def in &column.options {
2090            match option_def.option {
2091                ColumnOption::Null => {}
2092                ColumnOption::GeneratedColumns(_) => {
2093                    return Err(ErrorCode::InvalidInputSyntax(
2094                        "generated columns are not supported for webhook tables".to_owned(),
2095                    )
2096                    .into());
2097                }
2098                ColumnOption::DefaultValue(_) | ColumnOption::DefaultValueInternal { .. } => {
2099                    return Err(ErrorCode::InvalidInputSyntax(
2100                        "default values are not supported for webhook tables".to_owned(),
2101                    )
2102                    .into());
2103                }
2104                ColumnOption::NotNull
2105                | ColumnOption::Unique { .. }
2106                | ColumnOption::ForeignKey { .. }
2107                | ColumnOption::Check(_)
2108                | ColumnOption::DialectSpecific(_) => {
2109                    return Err(ErrorCode::InvalidInputSyntax(
2110                        "only NULL column option is supported for webhook tables".to_owned(),
2111                    )
2112                    .into());
2113                }
2114            }
2115        }
2116    }
2117
2118    // validate secret_ref
2119    let (pb_secret_ref, secret_name) = if let Some(secret_ref) = secret_ref {
2120        let db_name = &session.database();
2121        let (schema_name, secret_name) =
2122            Binder::resolve_schema_qualified_name(db_name, &secret_ref.secret_name)?;
2123        let secret_catalog = session.get_secret_by_name(schema_name, &secret_name)?;
2124        (
2125            Some(PbSecretRef {
2126                secret_id: secret_catalog.id,
2127                ref_as: match secret_ref.ref_as {
2128                    SecretRefAsType::Text => PbRefAsType::Text,
2129                    SecretRefAsType::File => PbRefAsType::File,
2130                }
2131                .into(),
2132            }),
2133            Some(secret_name),
2134        )
2135    } else {
2136        (None, None)
2137    };
2138
2139    let signature_expr = if let Some(signature_expr) = signature_expr {
2140        let payload_name = if column_defs.len() == 1
2141            && column_defs[0].data_type.as_ref() == Some(&AstDataType::Jsonb)
2142        {
2143            column_defs[0].name.real_value()
2144        } else {
2145            WEBHOOK_PAYLOAD_FIELD_NAME.to_owned()
2146        };
2147        let secure_compare_context = SecureCompareContext {
2148            payload_name,
2149            secret_name,
2150        };
2151        let mut binder = Binder::new_for_ddl(session).with_secure_compare(secure_compare_context);
2152        let expr = binder.bind_expr(&signature_expr)?;
2153
2154        // validate expr, ensuring it is SECURE_COMPARE()
2155        if expr.as_function_call().is_none()
2156            || expr.as_function_call().unwrap().func_type()
2157                != crate::optimizer::plan_node::generic::ExprType::SecureCompare
2158        {
2159            return Err(ErrorCode::InvalidInputSyntax(
2160                "The signature verification function must be SECURE_COMPARE()".to_owned(),
2161            )
2162            .into());
2163        }
2164
2165        Some(expr.to_expr_proto())
2166    } else {
2167        session.notice_to_user(
2168            "VALIDATE clause is strongly recommended for safety or production usages",
2169        );
2170        None
2171    };
2172
2173    let pb_webhook_info = PbWebhookSourceInfo {
2174        secret_ref: pb_secret_ref,
2175        signature_expr,
2176        wait_for_persistence,
2177        is_batched,
2178    };
2179
2180    Ok(pb_webhook_info)
2181}
2182
2183#[cfg(test)]
2184mod tests {
2185    use risingwave_common::catalog::{
2186        DEFAULT_DATABASE_NAME, ROW_ID_COLUMN_NAME, RW_TIMESTAMP_COLUMN_NAME,
2187    };
2188    use risingwave_common::types::StructType;
2189
2190    use super::*;
2191    use crate::test_utils::{LocalFrontend, PROTO_FILE_DATA, create_proto_file};
2192
2193    #[tokio::test]
2194    async fn test_iceberg_engine_primary_key_types() {
2195        let frontend = LocalFrontend::new(Default::default()).await;
2196        for (primary_key, append_only, pk_index, supported) in [
2197            ("id, period", false, false, false),
2198            ("id, items", false, false, false),
2199            ("id, mapping", false, false, false),
2200            ("id, f", false, false, false),
2201            ("id, d", false, false, false),
2202            ("id", false, false, true),
2203            ("id, ts", false, false, true),
2204            ("", false, false, true),
2205            ("id, period", true, false, true),
2206            ("id, period", false, true, true),
2207        ] {
2208            let pk_clause = if primary_key.is_empty() {
2209                String::new()
2210            } else {
2211                format!(", PRIMARY KEY ({primary_key})")
2212            };
2213            frontend
2214                .run_sql(&format!(
2215                    "CREATE TABLE t (id INT, period STRUCT<start_ts TIMESTAMPTZ, end_ts TIMESTAMPTZ>, \
2216                     items INT[], mapping MAP(INT, INT), f REAL, d DOUBLE PRECISION, ts TIMESTAMPTZ \
2217                     {pk_clause}) {}",
2218                    if append_only { "APPEND ONLY" } else { "" }
2219                ))
2220                .await
2221                .unwrap();
2222            let session = frontend.session_ref();
2223            let table = session
2224                .env()
2225                .catalog_reader()
2226                .read_guard()
2227                .get_created_table_by_name(
2228                    DEFAULT_DATABASE_NAME,
2229                    SchemaPath::Name(DEFAULT_SCHEMA_NAME),
2230                    "t",
2231                )
2232                .unwrap()
2233                .0
2234                .clone();
2235            let pks = table
2236                .pk_column_names()
2237                .into_iter()
2238                .map(str::to_owned)
2239                .collect_vec();
2240            for write_mode in ["merge-on-read", "copy-on-write"] {
2241                if write_mode == "copy-on-write" && (append_only || pk_index) {
2242                    continue;
2243                }
2244                let options = BTreeMap::from([
2245                    ("connector".to_owned(), "iceberg".to_owned()),
2246                    ("catalog.type".to_owned(), "storage".to_owned()),
2247                    (
2248                        "warehouse.path".to_owned(),
2249                        "s3://test/warehouse".to_owned(),
2250                    ),
2251                    ("database.name".to_owned(), "public".to_owned()),
2252                    ("table.name".to_owned(), "t".to_owned()),
2253                    ("write_mode".to_owned(), write_mode.to_owned()),
2254                    ("enable_pk_index".to_owned(), pk_index.to_string()),
2255                ]);
2256                let result = build_iceberg_engine_sink_options(
2257                    options,
2258                    &WithOptions::default(),
2259                    &table,
2260                    &pks,
2261                );
2262                if supported {
2263                    result.unwrap();
2264                } else {
2265                    let err = result.unwrap_err().to_report_string();
2266                    let column = primary_key.split(", ").last().unwrap();
2267                    assert!(
2268                        err.contains(&format!(
2269                            "Iceberg engine table primary key column \"{column}\" has unsupported type"
2270                        )),
2271                        "{write_mode}: {err}"
2272                    );
2273                }
2274            }
2275            frontend.run_sql("DROP TABLE t").await.unwrap();
2276        }
2277    }
2278
2279    #[tokio::test]
2280    async fn test_cdc_relations_require_select_privilege_on_source() {
2281        let frontend = LocalFrontend::new(Default::default()).await;
2282        // PostgreSQL CDC with an explicit schema avoids querying upstream PK metadata,
2283        // so this privilege test does not require an external database.
2284        frontend
2285            .run_sql(
2286                r#"
2287                CREATE SOURCE cdc_source WITH (
2288                    connector = 'postgres-cdc',
2289                    hostname = 'localhost',
2290                    port = '5432',
2291                    username = 'postgres',
2292                    password = '',
2293                    database.name = 'db'
2294                ) FORMAT PLAIN ENCODE JSON
2295                "#,
2296            )
2297            .await
2298            .unwrap();
2299        frontend.run_sql("CREATE USER cdc_user").await.unwrap();
2300        frontend
2301            .run_sql("GRANT CREATE ON SCHEMA public TO cdc_user")
2302            .await
2303            .unwrap();
2304
2305        let user_id = frontend
2306            .session_ref()
2307            .env()
2308            .user_info_reader()
2309            .read_guard()
2310            .get_user_by_name("cdc_user")
2311            .unwrap()
2312            .id;
2313        let user_session = frontend.session_user_ref(
2314            DEFAULT_DATABASE_NAME.to_owned(),
2315            "cdc_user".to_owned(),
2316            user_id,
2317        );
2318        let create_table =
2319            "CREATE TABLE cdc_table (id INT PRIMARY KEY) FROM cdc_source TABLE 'public.t'";
2320        let create_table_source = "CREATE SOURCE cdc_table_source (id INT PRIMARY KEY) \
2321             WITH (snapshot = 'false') FROM cdc_source TABLE 'public.t'";
2322
2323        for sql in [create_table, create_table_source] {
2324            let err = frontend
2325                .run_sql_with_session(user_session.clone(), sql)
2326                .await
2327                .unwrap_err();
2328            assert!(
2329                err.to_string()
2330                    .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2331                "{err:?}"
2332            );
2333        }
2334
2335        frontend
2336            .run_sql("GRANT SELECT ON SOURCE cdc_source TO cdc_user")
2337            .await
2338            .unwrap();
2339        frontend
2340            .run_sql_with_session(user_session.clone(), create_table)
2341            .await
2342            .unwrap();
2343        frontend
2344            .run_sql_with_session(user_session.clone(), create_table_source)
2345            .await
2346            .unwrap();
2347
2348        let table_source = {
2349            let catalog_reader = frontend.session_ref().env().catalog_reader().read_guard();
2350            catalog_reader
2351                .get_source_by_name(
2352                    DEFAULT_DATABASE_NAME,
2353                    SchemaPath::Name("public"),
2354                    "cdc_table_source",
2355                )
2356                .unwrap()
2357                .0
2358                .clone()
2359        };
2360        assert_eq!(table_source.with_properties.len(), 2);
2361        assert_eq!(
2362            table_source.with_properties.get_connector().as_deref(),
2363            Some("postgres-cdc")
2364        );
2365        assert_eq!(
2366            table_source
2367                .with_properties
2368                .get("snapshot")
2369                .map(String::as_str),
2370            Some("false")
2371        );
2372        assert!(table_source.with_properties.as_secret().is_empty());
2373        // The descriptor carries the resolved connect properties so that meta can validate it
2374        // against the upstream table; meta clears them before persisting the catalog, which is
2375        // covered by `test_create_cdc_table_source_dependency`.
2376        let external_table = table_source.info.external_table.as_ref().unwrap();
2377        assert!(!external_table.connect_properties.is_empty());
2378
2379        let err = frontend
2380            .run_sql_with_session(
2381                user_session.clone(),
2382                "SELECT * FROM postgres_query('cdc_table_source', 'SELECT 1')",
2383            )
2384            .await
2385            .unwrap_err();
2386        assert!(
2387            err.to_report_string()
2388                .contains("only accept shared CDC sources, not CDC table sources"),
2389            "{err:?}"
2390        );
2391
2392        frontend
2393            .run_sql("REVOKE SELECT ON SOURCE cdc_source FROM cdc_user")
2394            .await
2395            .unwrap();
2396        let err = frontend
2397            .run_sql_with_session(user_session, "ALTER TABLE cdc_table ADD COLUMN value INT")
2398            .await
2399            .unwrap_err();
2400        assert!(
2401            err.to_string()
2402                .contains("permission denied for source \"cdc_source\": \"SELECT\""),
2403            "{err:?}"
2404        );
2405    }
2406
2407    #[tokio::test]
2408    async fn test_create_table_handler() {
2409        let sql =
2410            "create table t (v1 smallint, v2 struct<v3 bigint, v4 float, v5 double>) append only;";
2411        let frontend = LocalFrontend::new(Default::default()).await;
2412        frontend.run_sql(sql).await.unwrap();
2413
2414        let session = frontend.session_ref();
2415        let catalog_reader = session.env().catalog_reader().read_guard();
2416        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2417
2418        // Check table exists.
2419        let (table, _) = catalog_reader
2420            .get_created_table_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2421            .unwrap();
2422        assert_eq!(table.name(), "t");
2423
2424        let columns = table
2425            .columns
2426            .iter()
2427            .map(|col| (col.name(), col.data_type().clone()))
2428            .collect::<HashMap<&str, DataType>>();
2429
2430        let expected_columns = maplit::hashmap! {
2431            ROW_ID_COLUMN_NAME => DataType::Serial,
2432            "v1" => DataType::Int16,
2433            "v2" => StructType::new(
2434                vec![("v3", DataType::Int64),("v4", DataType::Float64),("v5", DataType::Float64)],
2435            )
2436            .with_ids([3, 4, 5].map(ColumnId::new))
2437            .into(),
2438            RW_TIMESTAMP_COLUMN_NAME => DataType::Timestamptz,
2439        };
2440
2441        assert_eq!(columns, expected_columns, "{columns:#?}");
2442    }
2443
2444    #[tokio::test]
2445    async fn test_create_webhook_table_with_arbitrary_columns() {
2446        let frontend = LocalFrontend::new(Default::default()).await;
2447        frontend
2448            .run_sql("create schema ingest_schema;")
2449            .await
2450            .unwrap();
2451        frontend
2452            .run_sql(
2453                r#"
2454                create table ingest_schema.orders (
2455                    id int,
2456                    customer_name varchar,
2457                    amount double precision,
2458                    primary key (id)
2459                ) with (
2460                    connector = 'webhook'
2461                ) validate as secure_compare(
2462                    headers->>'x-rw-signature',
2463                    'sha256=' || encode(hmac('webhook-secret', payload, 'sha256'), 'hex')
2464                );
2465                "#,
2466            )
2467            .await
2468            .unwrap();
2469
2470        let session = frontend.session_ref();
2471        let catalog_reader = session.env().catalog_reader().read_guard();
2472        let (table, _) = catalog_reader
2473            .get_created_table_by_name(
2474                DEFAULT_DATABASE_NAME,
2475                SchemaPath::Name("ingest_schema"),
2476                "orders",
2477            )
2478            .unwrap();
2479
2480        assert!(table.webhook_info.is_some());
2481        assert_eq!(
2482            table
2483                .columns
2484                .iter()
2485                .filter(|column| column.can_dml())
2486                .count(),
2487            3
2488        );
2489    }
2490
2491    #[tokio::test]
2492    async fn test_create_webhook_table_uses_single_jsonb_column_name_in_validate() {
2493        let frontend = LocalFrontend::new(Default::default()).await;
2494        frontend
2495            .run_sql(
2496                r#"
2497                create table webhook_single_column (
2498                    body jsonb
2499                ) with (
2500                    connector = 'webhook'
2501                ) validate as secure_compare(
2502                    headers->>'x-rw-signature',
2503                    'sha256=' || encode(hmac('webhook-secret', body, 'sha256'), 'hex')
2504                );
2505                "#,
2506            )
2507            .await
2508            .unwrap();
2509    }
2510
2511    #[tokio::test]
2512    async fn test_create_webhook_table_with_generated_columns() {
2513        let frontend = LocalFrontend::new(Default::default()).await;
2514        let err = frontend
2515            .run_sql(
2516                r#"
2517                create table webhook_generated_columns (
2518                    id int,
2519                    amount double precision,
2520                    amount_with_fee double precision as amount + 1.0
2521                ) with (
2522                    connector = 'webhook'
2523                );
2524                "#,
2525            )
2526            .await
2527            .unwrap_err();
2528
2529        assert!(
2530            err.to_string()
2531                .contains("generated columns are not supported for webhook tables"),
2532            "{err:?}"
2533        );
2534    }
2535
2536    #[tokio::test]
2537    async fn test_create_webhook_table_with_default_value() {
2538        let frontend = LocalFrontend::new(Default::default()).await;
2539        let err = frontend
2540            .run_sql(
2541                r#"
2542                create table webhook_default_value (
2543                    id int default 42,
2544                    amount double precision
2545                ) with (
2546                    connector = 'webhook'
2547                );
2548                "#,
2549            )
2550            .await
2551            .unwrap_err();
2552
2553        assert!(
2554            err.to_string()
2555                .contains("default values are not supported for webhook tables"),
2556            "{err:?}"
2557        );
2558    }
2559
2560    #[tokio::test]
2561    async fn test_create_webhook_table_with_not_null_option() {
2562        let frontend = LocalFrontend::new(Default::default()).await;
2563        let err = frontend
2564            .run_sql(
2565                r#"
2566                create table webhook_not_null (
2567                    id int not null,
2568                    amount double precision
2569                ) with (
2570                    connector = 'webhook'
2571                );
2572                "#,
2573            )
2574            .await
2575            .unwrap_err();
2576
2577        assert!(
2578            err.to_string()
2579                .contains("only NULL column option is supported for webhook tables"),
2580            "{err:?}"
2581        );
2582    }
2583
2584    #[test]
2585    fn test_bind_primary_key() {
2586        // Note: Column ID 0 is reserved for row ID column.
2587
2588        for (sql, expected) in [
2589            ("create table t (v1 int, v2 int)", Ok(&[0] as &[_])),
2590            ("create table t (v1 int primary key, v2 int)", Ok(&[1])),
2591            ("create table t (v1 int, v2 int primary key)", Ok(&[2])),
2592            (
2593                "create table t (v1 int primary key, v2 int primary key)",
2594                Err("multiple primary keys are not allowed"),
2595            ),
2596            (
2597                "create table t (v1 int primary key primary key, v2 int)",
2598                Err("multiple primary keys are not allowed"),
2599            ),
2600            (
2601                "create table t (v1 int, v2 int, primary key (v1))",
2602                Ok(&[1]),
2603            ),
2604            (
2605                "create table t (v1 int, primary key (v2), v2 int)",
2606                Ok(&[2]),
2607            ),
2608            (
2609                "create table t (primary key (v2, v1), v1 int, v2 int)",
2610                Ok(&[2, 1]),
2611            ),
2612            (
2613                "create table t (v1 int, primary key (v1), v2 int, primary key (v1))",
2614                Err("multiple primary keys are not allowed"),
2615            ),
2616            (
2617                "create table t (v1 int primary key, primary key (v1), v2 int)",
2618                Err("multiple primary keys are not allowed"),
2619            ),
2620            (
2621                "create table t (v1 int, primary key (V3), v2 int)",
2622                Err("column \"v3\" named in key does not exist"),
2623            ),
2624        ] {
2625            let mut ast = risingwave_sqlparser::parser::Parser::parse_sql(sql).unwrap();
2626            let risingwave_sqlparser::ast::Statement::CreateTable {
2627                columns: column_defs,
2628                constraints,
2629                ..
2630            } = ast.remove(0)
2631            else {
2632                panic!("test case should be create table")
2633            };
2634            let actual: Result<_> = (|| {
2635                let mut columns = bind_sql_columns(&column_defs, false)?;
2636                let mut col_id_gen = ColumnIdGenerator::new_initial();
2637                for c in &mut columns {
2638                    col_id_gen.generate(c)?;
2639                }
2640
2641                let pk_names =
2642                    bind_sql_pk_names(&column_defs, bind_table_constraints(&constraints)?)?;
2643                let (_, pk_column_ids, _) =
2644                    bind_pk_and_row_id_on_relation(columns, pk_names, true)?;
2645                Ok(pk_column_ids)
2646            })();
2647            match (expected, actual) {
2648                (Ok(expected), Ok(actual)) => assert_eq!(
2649                    expected.iter().copied().map(ColumnId::new).collect_vec(),
2650                    actual,
2651                    "sql: {sql}"
2652                ),
2653                (Ok(_), Err(actual)) => panic!("sql: {sql}\nunexpected error: {actual:?}"),
2654                (Err(_), Ok(actual)) => panic!("sql: {sql}\nexpects error but got: {actual:?}"),
2655                (Err(expected), Err(actual)) => assert!(
2656                    actual.to_string().contains(expected),
2657                    "sql: {sql}\nexpected: {expected:?}\nactual: {actual:?}"
2658                ),
2659            }
2660        }
2661    }
2662
2663    #[tokio::test]
2664    async fn test_duplicate_props_options() {
2665        let proto_file = create_proto_file(PROTO_FILE_DATA);
2666        let sql = format!(
2667            r#"CREATE TABLE t
2668    WITH (
2669        connector = 'kinesis',
2670        aws.region='user_test_topic',
2671        endpoint='172.10.1.1:9090,172.10.1.2:9090',
2672        aws.credentials.access_key_id = 'your_access_key_1',
2673        aws.credentials.secret_access_key = 'your_secret_key_1'
2674    )
2675    FORMAT PLAIN ENCODE PROTOBUF (
2676        message = '.test.TestRecord',
2677        aws.credentials.access_key_id = 'your_access_key_2',
2678        aws.credentials.secret_access_key = 'your_secret_key_2',
2679        schema.location = 'file://{}',
2680    )"#,
2681            proto_file.path().to_str().unwrap()
2682        );
2683        let frontend = LocalFrontend::new(Default::default()).await;
2684        frontend.run_sql(sql).await.unwrap();
2685
2686        let session = frontend.session_ref();
2687        let catalog_reader = session.env().catalog_reader().read_guard();
2688        let schema_path = SchemaPath::Name(DEFAULT_SCHEMA_NAME);
2689
2690        // Check source exists.
2691        let (source, _) = catalog_reader
2692            .get_source_by_name(DEFAULT_DATABASE_NAME, schema_path, "t")
2693            .unwrap();
2694        assert_eq!(source.name, "t");
2695
2696        // AwsAuth params exist in options.
2697        assert_eq!(
2698            source
2699                .info
2700                .format_encode_options
2701                .get("aws.credentials.access_key_id")
2702                .unwrap(),
2703            "your_access_key_2"
2704        );
2705        assert_eq!(
2706            source
2707                .info
2708                .format_encode_options
2709                .get("aws.credentials.secret_access_key")
2710                .unwrap(),
2711            "your_secret_key_2"
2712        );
2713
2714        // AwsAuth params exist in props.
2715        assert_eq!(
2716            source
2717                .with_properties
2718                .get("aws.credentials.access_key_id")
2719                .unwrap(),
2720            "your_access_key_1"
2721        );
2722        assert_eq!(
2723            source
2724                .with_properties
2725                .get("aws.credentials.secret_access_key")
2726                .unwrap(),
2727            "your_secret_key_1"
2728        );
2729
2730        // Options are not merged into props.
2731        assert!(!source.with_properties.contains_key("schema.location"));
2732    }
2733}