1use 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
36pub(crate) fn derive_connect_properties_for_cdc_table(
45 source_with_properties: &WithOptionsSecResolved,
46 external_table_name: String,
47) -> Result<(WithOptionsSecResolved, String)> {
48 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 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 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 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 with_options.insert(SCHEMA_NAME_KEY.into(), schema_name);
85 with_options.insert(TABLE_NAME_KEY.into(), table_name);
86 return Ok((with_options, external_table_name));
88 }
89 SQL_SERVER_CDC_CONNECTOR => {
90 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 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 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 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 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
161fn 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
183pub(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
298pub(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
319pub(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 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 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
383pub(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 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
424pub(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 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}