1use risingwave_connector::source::{ADBC_SNOWFLAKE_CONNECTOR, BATCH_POSIX_FS_CONNECTOR};
16
17use super::*;
18
19pub static SOURCE_ALLOWED_CONNECTION_CONNECTOR: LazyLock<HashSet<PbConnectionType>> =
20 LazyLock::new(|| {
21 hashset! {
22 PbConnectionType::Unspecified,
23 PbConnectionType::Kafka,
24 PbConnectionType::Iceberg,
25 }
26 });
27
28pub static SOURCE_ALLOWED_CONNECTION_SCHEMA_REGISTRY: LazyLock<HashSet<PbConnectionType>> =
29 LazyLock::new(|| {
30 hashset! {
31 PbConnectionType::Unspecified,
32 PbConnectionType::SchemaRegistry,
33 }
34 });
35
36static CONNECTORS_COMPATIBLE_FORMATS: LazyLock<HashMap<String, HashMap<Format, Vec<Encode>>>> =
38 LazyLock::new(|| {
39 convert_args!(hashmap!(
40 KAFKA_CONNECTOR => hashmap!(
41 Format::Plain => vec![Encode::Json, Encode::Protobuf, Encode::Avro, Encode::Bytes, Encode::Csv],
42 Format::Upsert => vec![Encode::Json, Encode::Avro, Encode::Protobuf],
43 Format::Debezium => vec![Encode::Json, Encode::Avro],
44 Format::DebeziumMongo => vec![Encode::Json],
45 ),
46 PULSAR_CONNECTOR => hashmap!(
47 Format::Plain => vec![Encode::Json, Encode::Protobuf, Encode::Avro, Encode::Bytes],
48 Format::Upsert => vec![Encode::Json, Encode::Avro],
49 Format::Debezium => vec![Encode::Json],
50 ),
51 KINESIS_CONNECTOR => hashmap!(
52 Format::Plain => vec![Encode::Json, Encode::Protobuf, Encode::Avro, Encode::Bytes, Encode::Csv],
53 Format::Upsert => vec![Encode::Json, Encode::Avro],
54 Format::Debezium => vec![Encode::Json],
55 ),
56 GOOGLE_PUBSUB_CONNECTOR => hashmap!(
57 Format::Plain => vec![Encode::Json, Encode::Protobuf, Encode::Avro, Encode::Bytes],
58 Format::Debezium => vec![Encode::Json],
59 ),
60 NEXMARK_CONNECTOR => hashmap!(
61 Format::Native => vec![Encode::Native],
62 Format::Plain => vec![Encode::Bytes],
63 ),
64 DATAGEN_CONNECTOR => hashmap!(
65 Format::Native => vec![Encode::Native],
66 Format::Plain => vec![Encode::Bytes, Encode::Json],
67 ),
68 OPENDAL_S3_CONNECTOR => hashmap!(
69 Format::Plain => vec![Encode::Csv, Encode::Json, Encode::Parquet],
70 ),
71 GCS_CONNECTOR => hashmap!(
72 Format::Plain => vec![Encode::Csv, Encode::Json, Encode::Parquet],
73 ),
74 AZBLOB_CONNECTOR => hashmap!(
75 Format::Plain => vec![Encode::Csv, Encode::Json, Encode::Parquet],
76 ),
77 POSIX_FS_CONNECTOR => hashmap!(
78 Format::Plain => vec![Encode::Csv, Encode::Json, Encode::Parquet],
79 ),
80 BATCH_POSIX_FS_CONNECTOR => hashmap!(
81 Format::Plain => vec![Encode::Csv],
82 ),
83 MYSQL_CDC_CONNECTOR => hashmap!(
84 Format::Debezium => vec![Encode::Json],
85 Format::Plain => vec![Encode::Json],
87 ),
88 POSTGRES_CDC_CONNECTOR => hashmap!(
89 Format::Debezium => vec![Encode::Json],
90 Format::Plain => vec![Encode::Json],
92 ),
93 ORACLE_CDC_CONNECTOR => hashmap!(
94 Format::Debezium => vec![Encode::Json],
95 Format::Plain => vec![Encode::Json],
97 ),
98 MONGODB_CDC_CONNECTOR => hashmap!(
99 Format::DebeziumMongo => vec![Encode::Json],
100 ),
101 NATS_CONNECTOR => hashmap!(
102 Format::Plain => vec![Encode::Json, Encode::Protobuf, Encode::Bytes],
103 ),
104 MQTT_CONNECTOR => hashmap!(
105 Format::Plain => vec![Encode::Json, Encode::Protobuf, Encode::Bytes],
106 ),
107 TEST_CONNECTOR => hashmap!(
108 Format::Plain => vec![Encode::Json],
109 ),
110 ICEBERG_CONNECTOR => hashmap!(
111 Format::None => vec![Encode::None],
112 ),
113 ADBC_SNOWFLAKE_CONNECTOR => hashmap!(
114 Format::None => vec![Encode::None],
115 ),
116 SQL_SERVER_CDC_CONNECTOR => hashmap!(
117 Format::Debezium => vec![Encode::Json],
118 Format::Plain => vec![Encode::Json],
120 ),
121 ))
122 });
123
124fn validate_license(connector: &str) -> Result<()> {
125 if connector == SQL_SERVER_CDC_CONNECTOR {
126 Feature::SqlServerCdcSource.check_available()?;
127 }
128 Ok(())
129}
130
131fn validate_decimal_handling_mode(props: &BTreeMap<String, String>) -> Result<()> {
132 if let Some(mode) = props.get("debezium.decimal.handling.mode")
133 && mode != "string"
134 {
135 return Err(RwError::from(ProtocolError(format!(
136 "'debezium.decimal.handling.mode' must be 'string', got: '{mode}'"
137 ))));
138 }
139 Ok(())
140}
141
142pub fn validate_heartbeat_interval(props: &BTreeMap<String, String>) -> Result<()> {
148 let Some(value) = props.get("debezium.heartbeat.interval.ms") else {
149 return Ok(());
150 };
151
152 if !value.parse::<i32>().is_ok_and(|interval| interval > 0) {
154 return Err(ErrorCode::InvalidParameterValue(format!(
155 "'debezium.heartbeat.interval.ms' must be a positive integer, got: '{value}'"
156 ))
157 .into());
158 }
159 Ok(())
160}
161
162fn validate_pulsar_schema_options(
166 format_encode: &FormatEncodeOptions,
167 connector: &str,
168) -> Result<bool> {
169 let options = WithOptions::try_from(format_encode.row_options())?;
170 if !options.contains_key(PULSAR_SCHEMA_URL_KEY)
171 && !options.secret_ref().contains_key(PULSAR_SCHEMA_URL_KEY)
172 {
173 return Ok(false);
174 }
175
176 if connector != PULSAR_CONNECTOR
177 || format_encode.format != Format::Plain
178 || format_encode.row_encode != Encode::Avro
179 {
180 return Err(RwError::from(ProtocolError(format!(
181 "Pulsar schema requires connector = '{PULSAR_CONNECTOR}' with FORMAT PLAIN ENCODE AVRO"
182 ))));
183 }
184
185 Ok(true)
186}
187
188pub fn validate_compatibility(
189 format_encode: &FormatEncodeOptions,
190 props: &mut BTreeMap<String, String>,
191) -> Result<()> {
192 let mut connector = props
193 .get_connector()
194 .ok_or_else(|| RwError::from(ProtocolError("missing field 'connector'".to_owned())))?;
195
196 if connector == OPENDAL_S3_CONNECTOR {
197 return Err(RwError::from(Deprecated(
199 OPENDAL_S3_CONNECTOR.to_owned(),
200 LEGACY_S3_CONNECTOR.to_owned(),
201 )));
202 }
203 if connector == LEGACY_S3_CONNECTOR {
204 let entry = props.get_mut(UPSTREAM_SOURCE_KEY).unwrap();
207 *entry = OPENDAL_S3_CONNECTOR.to_owned();
208 connector = OPENDAL_S3_CONNECTOR.to_owned();
209 }
210
211 let compatible_formats = CONNECTORS_COMPATIBLE_FORMATS
212 .get(&connector)
213 .ok_or_else(|| {
214 RwError::from(ProtocolError(format!(
215 "connector {:?} is not supported, accept {:?}",
216 connector,
217 CONNECTORS_COMPATIBLE_FORMATS.keys()
218 )))
219 })?;
220
221 validate_decimal_handling_mode(props)?;
225
226 validate_license(&connector)?;
227 let uses_pulsar_schema = validate_pulsar_schema_options(format_encode, &connector)?;
228 if connector != KAFKA_CONNECTOR && !uses_pulsar_schema {
229 let res = match (&format_encode.format, &format_encode.row_encode) {
230 (Format::Plain, Encode::Protobuf) | (Format::Plain, Encode::Avro) => {
231 let mut options = WithOptions::try_from(format_encode.row_options())?;
232 let (_, use_schema_registry) = get_schema_location(options.inner_mut())?;
233 use_schema_registry
234 }
235 (Format::Debezium, Encode::Avro) => true,
236 (_, _) => false,
237 };
238 if res {
239 return Err(RwError::from(ProtocolError(format!(
240 "The {} must be kafka when schema registry is used",
241 UPSTREAM_SOURCE_KEY
242 ))));
243 }
244 }
245
246 let compatible_encodes = compatible_formats
247 .get(&format_encode.format)
248 .ok_or_else(|| {
249 RwError::from(ProtocolError(format!(
250 "connector {} does not support format {:?}",
251 connector, format_encode.format
252 )))
253 })?;
254 if !compatible_encodes.contains(&format_encode.row_encode) {
255 return Err(RwError::from(ProtocolError(format!(
256 "connector {} does not support format {:?} with encode {:?}",
257 connector, format_encode.format, format_encode.row_encode
258 ))));
259 }
260
261 if connector == POSTGRES_CDC_CONNECTOR || connector == CITUS_CDC_CONNECTOR {
262 match props.get("slot.name") {
263 None => {
264 let uuid = uuid::Uuid::new_v4();
267 props.insert("slot.name".into(), format!("rw_cdc_{}", uuid.simple()));
268 }
269 Some(slot_name) => {
270 if !slot_name
274 .chars()
275 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
276 || slot_name.len() > 63
277 {
278 return Err(RwError::from(ProtocolError(format!(
279 "Invalid replication slot name: {:?}. Valid replication slot name must contain only digits, lowercase characters and underscores with length <= 63",
280 slot_name
281 ))));
282 }
283 }
284 }
285
286 if !props.contains_key("schema.name") {
287 props.insert("schema.name".into(), "public".into());
289 }
290 if !props.contains_key("publication.name") {
291 let uuid = uuid::Uuid::new_v4();
294 props.insert(
295 "publication.name".into(),
296 format!("rw_publication_{}", uuid.simple()),
297 );
298 }
299 if !props.contains_key("publication.create.enable") {
300 props.insert("publication.create.enable".into(), "true".into());
302 }
303 }
304
305 if connector == SQL_SERVER_CDC_CONNECTOR && !props.contains_key("schema.name") {
306 props.insert("schema.name".into(), "dbo".into());
308 }
309
310 if (connector == MYSQL_CDC_CONNECTOR
312 || connector == POSTGRES_CDC_CONNECTOR
313 || connector == CITUS_CDC_CONNECTOR
314 || connector == MONGODB_CDC_CONNECTOR
315 || connector == SQL_SERVER_CDC_CONNECTOR
316 || connector == ORACLE_CDC_CONNECTOR)
317 && let Some(timeout_value) = props.get("cdc.source.wait.streaming.start.timeout")
318 && timeout_value.parse::<u32>().is_err()
319 {
320 return Err(ErrorCode::InvalidConfigValue {
321 config_entry: "cdc.source.wait.streaming.start.timeout".to_owned(),
322 config_value: timeout_value.to_owned(),
323 }
324 .into());
325 }
326
327 if (connector == MYSQL_CDC_CONNECTOR
329 || connector == POSTGRES_CDC_CONNECTOR
330 || connector == CITUS_CDC_CONNECTOR
331 || connector == MONGODB_CDC_CONNECTOR
332 || connector == SQL_SERVER_CDC_CONNECTOR
333 || connector == ORACLE_CDC_CONNECTOR)
334 && let Some(queue_size_value) = props.get("debezium.max.queue.size")
335 && queue_size_value.parse::<u32>().is_err()
336 {
337 return Err(ErrorCode::InvalidConfigValue {
338 config_entry: "debezium.max.queue.size".to_owned(),
339 config_value: queue_size_value.to_owned(),
340 }
341 .into());
342 }
343
344 validate_heartbeat_interval(props)
345}
346
347#[cfg(test)]
348mod tests {
349 use risingwave_sqlparser::ast::SqlOption;
350
351 use super::*;
352
353 fn format_encode(
354 format: Format,
355 encode: Encode,
356 options: &[(&str, &str)],
357 ) -> FormatEncodeOptions {
358 let row_options = options
359 .iter()
360 .map(|(name, value)| {
361 let name = name.to_string();
362 let value = value.to_string();
363 SqlOption::try_from((&name, &value)).unwrap()
364 })
365 .collect();
366 FormatEncodeOptions {
367 format,
368 row_encode: encode,
369 row_options,
370 key_encode: None,
371 }
372 }
373
374 fn source_options(connector: &str) -> BTreeMap<String, String> {
375 BTreeMap::from([(UPSTREAM_SOURCE_KEY.to_owned(), connector.to_owned())])
376 }
377
378 #[test]
379 fn pulsar_schema_accepts_minimal_plain_avro_options() {
380 let format_encode = format_encode(
381 Format::Plain,
382 Encode::Avro,
383 &[
384 (PULSAR_SCHEMA_URL_KEY, "https://localhost:8443"),
385 (PULSAR_SCHEMA_AUTH_TOKEN_KEY, "schema-token"),
386 ],
387 );
388 validate_compatibility(&format_encode, &mut source_options(PULSAR_CONNECTOR)).unwrap();
389 }
390
391 #[test]
392 fn pulsar_schema_rejects_other_connectors_and_formats() {
393 for (connector, format, encode) in [
394 (KAFKA_CONNECTOR, Format::Plain, Encode::Avro),
395 (PULSAR_CONNECTOR, Format::Upsert, Encode::Avro),
396 (PULSAR_CONNECTOR, Format::Plain, Encode::Protobuf),
397 (PULSAR_CONNECTOR, Format::Plain, Encode::Json),
398 ] {
399 let format_encode = format_encode(
400 format,
401 encode,
402 &[(PULSAR_SCHEMA_URL_KEY, "http://localhost:8080")],
403 );
404 let error =
405 validate_compatibility(&format_encode, &mut source_options(connector)).unwrap_err();
406 assert!(error.to_string().contains(
407 "Pulsar schema requires connector = 'pulsar' with FORMAT PLAIN ENCODE AVRO"
408 ));
409 }
410 }
411
412 #[test]
413 fn confluent_schema_registry_behavior_is_unchanged() {
414 let format_encode = format_encode(
415 Format::Plain,
416 Encode::Avro,
417 &[("schema.registry", "http://localhost:8081")],
418 );
419 validate_compatibility(&format_encode, &mut source_options(KAFKA_CONNECTOR)).unwrap();
420 }
421}