Skip to main content

risingwave_frontend/handler/
cdc.rs

1// Copyright 2026 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 anyhow::{Context, anyhow};
16use fancy_regex::Regex;
17use itertools::Itertools;
18use risingwave_common::catalog::{CdcKeyComparison, ColumnCatalog};
19use risingwave_connector::WithOptionsSecResolved;
20use risingwave_connector::source::UPSTREAM_SOURCE_KEY;
21use risingwave_connector::source::cdc::external::{
22    DATABASE_NAME_KEY, ExternalTableConfig, ExternalTableImpl, SCHEMA_NAME_KEY, SchemaTableName,
23    TABLE_NAME_KEY,
24};
25use risingwave_connector::source::cdc::{
26    CdcSourceExternalTableIdentifier, MARIADB_CDC_CONNECTOR, MYSQL_CDC_CONNECTOR,
27    POSTGRES_CDC_CONNECTOR, Postgres, SQL_SERVER_CDC_CONNECTOR, SqlServer,
28};
29use risingwave_sqlparser::ast::{ColumnDef, ColumnOption, SourceWatermark, TableConstraint};
30use thiserror_ext::AsReport;
31
32use crate::error::{ErrorCode, Result, RwError};
33use crate::handler::create_source::reject_variant_columns;
34use crate::handler::create_table::{bind_sql_columns, bind_sql_pk_names, bind_table_constraints};
35
36/// Derive the connect properties of a CDC table from the properties of its source and the
37/// `external_table_name` in its `TABLE '...'` clause.
38///
39/// Returns (`connect_properties`, `external_table_name`) where `external_table_name` keeps the
40/// user's spelling:
41/// - For SQL Server: 'schema.table' (2 parts) gets the database name of the source prepended, so
42///   that it names the table the same way as Debezium's topic, 'database.schema.table'.
43/// - For MySQL/Postgres: Returns the original `external_table_name` unchanged.
44pub(crate) fn derive_connect_properties_for_cdc_table(
45    source_with_properties: &WithOptionsSecResolved,
46    external_table_name: String,
47) -> Result<(WithOptionsSecResolved, String)> {
48    // we should remove the prefix from `full_table_name`
49    let source_database_name: &str = source_with_properties
50        .get("database.name")
51        .ok_or_else(|| anyhow!("The source with properties does not contain 'database.name'"))?
52        .as_str();
53    let mut with_options = source_with_properties.clone();
54    if let Some(connector) = source_with_properties.get(UPSTREAM_SOURCE_KEY) {
55        match connector.as_str() {
56            MYSQL_CDC_CONNECTOR | MARIADB_CDC_CONNECTOR => {
57                // MySQL doesn't allow '.' in database name and table name, so we can split the
58                // external table name by '.' to get the table name
59                let (db_name, table_name) = external_table_name.split_once('.').ok_or_else(|| {
60                    anyhow!("The upstream table name must contain database name prefix, e.g. 'database.table'")
61                })?;
62                // We allow multiple database names in the source definition
63                if !source_database_name
64                    .split(',')
65                    .map(|s| s.trim())
66                    .any(|name| name == db_name)
67                {
68                    return Err(anyhow!(
69                        "The database name `{}` in the FROM clause is not included in the database name `{}` in source definition",
70                        db_name,
71                        source_database_name
72                    ).into());
73                }
74                with_options.insert(DATABASE_NAME_KEY.into(), db_name.into());
75                with_options.insert(TABLE_NAME_KEY.into(), table_name.into());
76                // Return original external_table_name unchanged for MySQL
77                return Ok((with_options, external_table_name));
78            }
79            POSTGRES_CDC_CONNECTOR => {
80                let (schema_name, table_name) =
81                    parse_postgres_cdc_external_table_name(&external_table_name)?;
82
83                // insert 'schema.name' into connect properties
84                with_options.insert(SCHEMA_NAME_KEY.into(), schema_name);
85                with_options.insert(TABLE_NAME_KEY.into(), table_name);
86                // Return original external_table_name unchanged for Postgres
87                return Ok((with_options, external_table_name));
88            }
89            SQL_SERVER_CDC_CONNECTOR => {
90                // SQL Server external table name must be in one of two formats:
91                // 1. 'schemaName.tableName' (2 parts) - database is already specified in source
92                // 2. 'databaseName.schemaName.tableName' (3 parts) - for explicit verification
93                //
94                // We do NOT allow single table name (e.g., 't') because:
95                // - Unlike database name (already in source), schema name is NOT pre-specified
96                // - User must explicitly provide schema (even if it's 'dbo')
97                let invalid_format = || {
98                    anyhow!(
99                        "Invalid table name format '{}'. Expected 'schema.table' or 'database.schema.table'.",
100                        external_table_name
101                    )
102                };
103                let parts = SqlServer::split_quoted_identifier(&external_table_name)
104                    .ok_or_else(invalid_format)?;
105                let (schema_name, table_name, external_table_name) = match parts.as_slice() {
106                    [db_name, schema_name, table_name] => {
107                        // Format: database.schema.table
108                        // Verify that the database name matches the one in source definition
109                        if db_name != source_database_name {
110                            return Err(anyhow!(
111                                "The database name '{}' in FROM clause does not match the database name '{}' specified in source definition. \
112                                 You can either use 'schema.table' format (recommended) or ensure the database name matches.",
113                                db_name,
114                                source_database_name
115                            ).into());
116                        }
117                        (schema_name, table_name, external_table_name)
118                    }
119                    [schema_name, table_name] => {
120                        // Format: schema.table (recommended)
121                        // Database name is taken from source definition. It may contain `]`, so
122                        // wrap it in `[]` and escape `]` to keep `database.schema.table` idempotent.
123                        let external_table_name = format!(
124                            "[{}].{}",
125                            source_database_name.replace(']', "]]"),
126                            external_table_name
127                        );
128                        (schema_name, table_name, external_table_name)
129                    }
130                    [_] => {
131                        // Format: table only
132                        // Reject with clear error message
133                        return Err(anyhow!(
134                            "Invalid table name format '{}'. For SQL Server CDC, you must specify the schema name. \
135                             Use 'schema.table' format (e.g., 'dbo.{}') or 'database.schema.table' format (e.g., '{}.dbo.{}').",
136                            external_table_name,
137                            external_table_name,
138                            source_database_name,
139                            external_table_name
140                        ).into());
141                    }
142                    _ => return Err(invalid_format().into()),
143                };
144
145                // Insert schema and table names into connector properties
146                with_options.insert(SCHEMA_NAME_KEY.into(), schema_name.clone());
147                with_options.insert(TABLE_NAME_KEY.into(), table_name.clone());
148                return Ok((with_options, external_table_name));
149            }
150            _ => {
151                return Err(RwError::from(anyhow!(
152                    "connector {} is not supported for cdc table",
153                    connector
154                )));
155            }
156        };
157    }
158    unreachable!("All valid CDC connectors should have returned by now")
159}
160
161/// Parse the schema/table name from the CDC `TABLE` clause.
162///
163/// Column names do not need the same parsing here: wildcard schema derivation reads
164/// them from PostgreSQL catalogs after the exact table has been identified.
165fn parse_postgres_cdc_external_table_name(external_table_name: &str) -> Result<(String, String)> {
166    let parts = Postgres::split_quoted_identifier(external_table_name).ok_or_else(|| {
167        anyhow!(
168            "Invalid Postgres CDC table name '{}'. Expected 'schema.table'.",
169            external_table_name
170        )
171    })?;
172
173    if let [schema_name, table_name] = parts.as_slice() {
174        Ok((schema_name.clone(), table_name.clone()))
175    } else {
176        Err(
177            anyhow!("The upstream table name must contain schema name prefix, e.g. 'public.table'")
178                .into(),
179        )
180    }
181}
182
183/// Reject a CDC table when a primary-key column is filtered out of Debezium change-event
184/// values via `debezium.column.exclude.list` or `debezium.column.include.list`.
185///
186/// Debezium's column filters only apply to the change-event **value** payload. Message keys are
187/// always built from the upstream PRIMARY KEY and are not affected. If a PK column is filtered out
188/// of the value, RisingWave reads NULL for that PK column from the payload, causing silent data
189/// corruption: UPDATE turns into a fresh INSERT (PK mismatch with the original row) and DELETE
190/// silently no-ops.
191///
192/// Debezium entries are regex patterns matched against the fully qualified column name
193/// `<namespace>.<table>.<column>`, where namespace is `schema` for Postgres / SQL Server and
194/// `database` for MySQL.
195pub(crate) fn reject_pk_filtered_by_debezium_column_filter(
196    pk_names: &[String],
197    cdc_with_options: &WithOptionsSecResolved,
198) -> Result<()> {
199    const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
200    const INCLUDE_KEY: &str = "debezium.column.include.list";
201
202    let st = SchemaTableName::from_properties(cdc_with_options.as_plaintext());
203    reject_pk_filtered_by_debezium_column_filter_inner(
204        pk_names,
205        &st,
206        cdc_with_options.get(EXCLUDE_KEY).map(String::as_str),
207        cdc_with_options.get(INCLUDE_KEY).map(String::as_str),
208    )
209}
210
211fn reject_pk_filtered_by_debezium_column_filter_inner(
212    pk_names: &[String],
213    st: &SchemaTableName,
214    exclude_list: Option<&str>,
215    include_list: Option<&str>,
216) -> Result<()> {
217    const EXCLUDE_KEY: &str = "debezium.column.exclude.list";
218    const INCLUDE_KEY: &str = "debezium.column.include.list";
219
220    let pk_full_names = pk_names
221        .iter()
222        .map(|pk| (pk, format!("{}.{}.{}", st.schema_name, st.table_name, pk)))
223        .collect_vec();
224
225    if let Some(exclude_list) = exclude_list {
226        let patterns = compile_debezium_column_filter_patterns(EXCLUDE_KEY, exclude_list)?;
227        for (pk, pk_full_name) in &pk_full_names {
228            for (pattern, regex) in &patterns {
229                if regex.is_match(pk_full_name).map_err(|err| {
230                    ErrorCode::InvalidInputSyntax(format!(
231                        "failed to evaluate Debezium column filter pattern `{pattern}` in `{EXCLUDE_KEY}`: {}",
232                        err.as_report()
233                    ))
234                })? {
235                    return Err(ErrorCode::InvalidInputSyntax(format!(
236                        "primary key column `{pk}` is excluded by `{EXCLUDE_KEY}` pattern \
237                         `{pattern}`. Excluding a PK column causes silent data corruption: \
238                         Debezium keeps the PK in the message key but drops it from the payload, \
239                         so RisingWave cannot match UPDATE/DELETE events against the original row."
240                    ))
241                    .into());
242                }
243            }
244        }
245    }
246
247    if let Some(include_list) = include_list {
248        let patterns = compile_debezium_column_filter_patterns(INCLUDE_KEY, include_list)?;
249        for (pk, pk_full_name) in &pk_full_names {
250            let mut included = false;
251            for (_, regex) in &patterns {
252                if regex.is_match(pk_full_name).map_err(|err| {
253                    ErrorCode::InvalidInputSyntax(format!(
254                        "failed to evaluate Debezium column filter pattern in `{INCLUDE_KEY}`: {}",
255                        err.as_report()
256                    ))
257                })? {
258                    included = true;
259                    break;
260                }
261            }
262            if !included {
263                return Err(ErrorCode::InvalidInputSyntax(format!(
264                    "primary key column `{pk}` is not included by `{INCLUDE_KEY}`. Omitting a PK \
265                     column causes silent data corruption: Debezium keeps the PK in the message key \
266                     but drops it from the payload, so RisingWave cannot match UPDATE/DELETE events \
267                     against the original row."
268                ))
269                .into());
270            }
271        }
272    }
273
274    Ok(())
275}
276
277fn compile_debezium_column_filter_patterns(
278    key: &str,
279    filter_list: &str,
280) -> Result<Vec<(String, Regex)>> {
281    filter_list
282        .split(',')
283        .map(str::trim)
284        .filter(|pattern| !pattern.is_empty())
285        .map(|pattern| {
286            let anchored_pattern = format!("(?i:^(?:{pattern})$)");
287            let regex = Regex::new(&anchored_pattern).map_err(|err| {
288                ErrorCode::InvalidInputSyntax(format!(
289                    "invalid Debezium column filter pattern `{pattern}` in `{key}`: {}",
290                    err.as_report()
291                ))
292            })?;
293            Ok((pattern.to_owned(), regex))
294        })
295        .collect()
296}
297
298// For both table from cdc source and table with cdc connector
299pub(crate) fn not_null_check_for_cdc_table(
300    wildcard_idx: &Option<usize>,
301    column_defs: &Vec<ColumnDef>,
302) -> Result<()> {
303    if !wildcard_idx.is_some()
304        && column_defs.iter().any(|col| {
305            col.options
306                .iter()
307                .any(|opt| matches!(opt.option, ColumnOption::NotNull))
308        })
309    {
310        return Err(ErrorCode::NotSupported(
311            "CDC table with NOT NULL constraint is not supported".to_owned(),
312            "Please remove the NOT NULL constraint for columns".to_owned(),
313        )
314        .into());
315    }
316    Ok(())
317}
318
319// Only for table from cdc source
320pub(crate) fn sanity_check_for_table_on_cdc_source(
321    append_only: bool,
322    column_defs: &Vec<ColumnDef>,
323    wildcard_idx: &Option<usize>,
324    constraints: &Vec<TableConstraint>,
325    source_watermarks: &Vec<SourceWatermark>,
326) -> Result<()> {
327    // wildcard cannot be used with column definitions
328    if wildcard_idx.is_some() && !column_defs.is_empty() {
329        return Err(ErrorCode::NotSupported(
330            "wildcard(*) and column definitions cannot be used together".to_owned(),
331            "Remove the wildcard or column definitions".to_owned(),
332        )
333        .into());
334    }
335
336    // cdc table must have primary key constraint or primary key column
337    if !wildcard_idx.is_some()
338        && !constraints.iter().any(|c| {
339            matches!(
340                c,
341                TableConstraint::Unique {
342                    is_primary: true,
343                    ..
344                }
345            )
346        })
347        && !column_defs.iter().any(|col| {
348            col.options
349                .iter()
350                .any(|opt| matches!(opt.option, ColumnOption::Unique { is_primary: true }))
351        })
352    {
353        return Err(ErrorCode::NotSupported(
354            "CDC table without primary key constraint is not supported".to_owned(),
355            "Please define a primary key".to_owned(),
356        )
357        .into());
358    }
359
360    if append_only {
361        return Err(ErrorCode::NotSupported(
362            "append only modifier on the table created from a CDC source".into(),
363            "Remove the APPEND ONLY clause".into(),
364        )
365        .into());
366    }
367
368    if !source_watermarks.is_empty()
369        && source_watermarks
370            .iter()
371            .any(|watermark| !watermark.with_ttl)
372    {
373        return Err(ErrorCode::NotSupported(
374            "non-TTL watermark defined on the table created from a CDC source".into(),
375            "Use `WATERMARK ... WITH TTL` instead.".into(),
376        )
377        .into());
378    }
379
380    Ok(())
381}
382
383/// Derive the schema of a CDC table from its upstream external table.
384pub(crate) async fn bind_cdc_table_schema_externally(
385    cdc_with_options: WithOptionsSecResolved,
386) -> Result<(Vec<ColumnCatalog>, Vec<String>, Vec<CdcKeyComparison>)> {
387    let (options, secret_refs) = cdc_with_options.into_parts();
388    let config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
389        .context("failed to extract external table config")?;
390
391    let table = ExternalTableImpl::connect(config)
392        .await
393        .context("failed to auto derive table schema")?;
394
395    let pk_names = table.pk_names().clone();
396    let pk_comparisons = table.pk_column_comparisons(&pk_names)?;
397    Ok((
398        table
399            .column_descs()
400            .iter()
401            .cloned()
402            .map(|column_desc| ColumnCatalog {
403                column_desc,
404                is_hidden: false,
405            })
406            .collect(),
407        pk_names,
408        pk_comparisons,
409    ))
410}
411
412pub(crate) async fn bind_cdc_pk_comparisons_externally(
413    cdc_with_options: WithOptionsSecResolved,
414    pk_names: &[String],
415) -> Result<Vec<CdcKeyComparison>> {
416    // Replacement plans also use this path, so a successful schema change persists the current
417    // upstream comparison semantics in the new stream graph.
418    let (options, secret_refs) = cdc_with_options.into_parts();
419    let config = ExternalTableConfig::try_from_btreemap(options, secret_refs)
420        .context("failed to extract external table config")?;
421    Ok(ExternalTableImpl::discover_pk_column_comparisons(&config, pk_names).await?)
422}
423
424/// Derive the schema of a CDC table from explicit SQL columns and constraints.
425pub(crate) fn bind_cdc_table_schema(
426    column_defs: &Vec<ColumnDef>,
427    constraints: &Vec<TableConstraint>,
428    is_for_replace_plan: bool,
429) -> Result<(Vec<ColumnCatalog>, Vec<String>)> {
430    let columns = bind_sql_columns(column_defs, is_for_replace_plan)?;
431    // CDC parsers cannot produce variant values, and this path has no FORMAT/ENCODE gate.
432    reject_variant_columns(&columns, "on a table created from a CDC source")?;
433
434    let pk_names = bind_sql_pk_names(column_defs, bind_table_constraints(constraints)?)?;
435    Ok((columns, pk_names))
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn test_schema_table_name() -> SchemaTableName {
443        SchemaTableName {
444            schema_name: "public".to_owned(),
445            table_name: "orders".to_owned(),
446        }
447    }
448
449    fn pk_names() -> Vec<String> {
450        vec!["plan_id".to_owned(), "site_id".to_owned()]
451    }
452
453    #[test]
454    fn test_debezium_filter_rejects_literal_excluded_pk() {
455        let err = reject_pk_filtered_by_debezium_column_filter_inner(
456            &pk_names(),
457            &test_schema_table_name(),
458            Some("public.orders.site_id"),
459            None,
460        )
461        .unwrap_err();
462
463        assert!(err.to_report_string().contains("site_id"));
464        assert!(
465            err.to_report_string()
466                .contains("debezium.column.exclude.list")
467        );
468    }
469
470    #[test]
471    fn test_debezium_filter_rejects_include_list_missing_pk() {
472        let err = reject_pk_filtered_by_debezium_column_filter_inner(
473            &pk_names(),
474            &test_schema_table_name(),
475            None,
476            Some("public.orders.plan_id,public.orders.payload"),
477        )
478        .unwrap_err();
479
480        assert!(err.to_report_string().contains("site_id"));
481        assert!(
482            err.to_report_string()
483                .contains("debezium.column.include.list")
484        );
485    }
486
487    #[test]
488    fn test_debezium_filter_accepts_include_list_covering_all_pks() {
489        reject_pk_filtered_by_debezium_column_filter_inner(
490            &pk_names(),
491            &test_schema_table_name(),
492            None,
493            Some("public.orders.plan_id,public.orders.site_id,public.orders.payload"),
494        )
495        .unwrap();
496    }
497
498    #[test]
499    fn test_debezium_filter_matches_regex_patterns() {
500        reject_pk_filtered_by_debezium_column_filter_inner(
501            &pk_names(),
502            &test_schema_table_name(),
503            None,
504            Some(r"public[.]orders[.](plan_id|site_id),public[.]orders[.]payload"),
505        )
506        .unwrap();
507
508        let err = reject_pk_filtered_by_debezium_column_filter_inner(
509            &pk_names(),
510            &test_schema_table_name(),
511            Some(r".*[.]orders[.]site_id"),
512            None,
513        )
514        .unwrap_err();
515
516        assert!(err.to_report_string().contains("site_id"));
517    }
518
519    #[test]
520    fn test_debezium_filter_matches_patterns_case_insensitively() {
521        let err = reject_pk_filtered_by_debezium_column_filter_inner(
522            &pk_names(),
523            &test_schema_table_name(),
524            Some("PUBLIC.ORDERS.SITE_ID"),
525            None,
526        )
527        .unwrap_err();
528
529        assert!(err.to_report_string().contains("site_id"));
530
531        reject_pk_filtered_by_debezium_column_filter_inner(
532            &pk_names(),
533            &test_schema_table_name(),
534            None,
535            Some("Public.Orders.Plan_ID,Public.Orders.Site_ID"),
536        )
537        .unwrap();
538    }
539
540    #[test]
541    fn test_parse_postgres_cdc_external_table_name() {
542        for (input, expected) in [
543            ("public.Note", ("public", "Note")),
544            ("public.\"Note\"", ("public", "Note")),
545            (
546                "\"Mixed.Schema\".\"Note.Table\"",
547                ("Mixed.Schema", "Note.Table"),
548            ),
549            ("public.\"Note\"\"Archive\"", ("public", "Note\"Archive")),
550        ] {
551            assert_eq!(
552                parse_postgres_cdc_external_table_name(input).unwrap(),
553                (expected.0.to_owned(), expected.1.to_owned()),
554                "input: {input}"
555            );
556        }
557
558        for input in [
559            "Note",
560            "public.",
561            ".Note",
562            "public.\"Note",
563            "public.\"Note\"Archive",
564            "public.Note.Archive",
565        ] {
566            assert!(
567                parse_postgres_cdc_external_table_name(input).is_err(),
568                "input should be rejected: {input}"
569            );
570        }
571    }
572
573    fn sql_server_source_properties(database_name: &str) -> WithOptionsSecResolved {
574        WithOptionsSecResolved::new(
575            [
576                (UPSTREAM_SOURCE_KEY, SQL_SERVER_CDC_CONNECTOR),
577                (DATABASE_NAME_KEY, database_name),
578            ]
579            .into_iter()
580            .map(|(k, v)| (k.to_owned(), v.to_owned()))
581            .collect(),
582            Default::default(),
583        )
584    }
585
586    #[test]
587    fn test_derive_sql_server_connect_properties() {
588        for (database_name, input, expected_external_table_name, expected_table_name) in [
589            ("repro", "dbo.orders", "[repro].dbo.orders", "orders"),
590            ("repro", "repro.dbo.orders", "repro.dbo.orders", "orders"),
591            (
592                "repro",
593                "[repro].dbo.orders",
594                "[repro].dbo.orders",
595                "orders",
596            ),
597            ("repro", "dbo.[a.b]", "[repro].dbo.[a.b]", "a.b"),
598            ("repro", "repro.dbo.[a.b]", "repro.dbo.[a.b]", "a.b"),
599            ("repro", "dbo.[a]]b]", "[repro].dbo.[a]]b]", "a]b"),
600            ("my.db", "dbo.orders", "[my.db].dbo.orders", "orders"),
601            ("x]y", "dbo.orders", "[x]]y].dbo.orders", "orders"),
602        ] {
603            let source_properties = sql_server_source_properties(database_name);
604            let (props, external_table_name) =
605                derive_connect_properties_for_cdc_table(&source_properties, input.to_owned())
606                    .unwrap();
607            assert_eq!(
608                external_table_name, expected_external_table_name,
609                "input: {input}"
610            );
611            assert_eq!(props.get(SCHEMA_NAME_KEY).unwrap(), "dbo", "input: {input}");
612            assert_eq!(
613                props.get(TABLE_NAME_KEY).unwrap(),
614                expected_table_name,
615                "input: {input}"
616            );
617
618            let (props_again, external_table_name_again) = derive_connect_properties_for_cdc_table(
619                &source_properties,
620                external_table_name.clone(),
621            )
622            .unwrap();
623            assert_eq!(
624                external_table_name_again, external_table_name,
625                "input: {input}"
626            );
627            assert_eq!(
628                props_again.into_parts().0,
629                props.into_parts().0,
630                "input: {input}"
631            );
632        }
633
634        for input in [
635            "orders",
636            "other.dbo.orders",
637            "repro.dbo.a.b",
638            "dbo.[a.b",
639            "dbo.[a]b]",
640            "dbo.a[b",
641        ] {
642            assert!(
643                derive_connect_properties_for_cdc_table(
644                    &sql_server_source_properties("repro"),
645                    input.to_owned()
646                )
647                .is_err(),
648                "input should be rejected: {input}"
649            );
650        }
651    }
652}