1use std::collections::HashMap;
16
17use anyhow::{Context, anyhow};
18use chrono::{DateTime, NaiveDateTime};
19use futures::stream::BoxStream;
20use futures::{StreamExt, pin_mut, stream};
21use futures_async_stream::try_stream;
22use itertools::Itertools;
23use mysql_async::prelude::*;
24use mysql_common::params::Params;
25use mysql_common::value::Value;
26use risingwave_common::bail;
27use risingwave_common::catalog::{CDC_OFFSET_COLUMN_NAME, ColumnDesc, ColumnId, Field, Schema};
28use risingwave_common::row::OwnedRow;
29use risingwave_common::types::{DataType, Datum, Decimal, F32, ScalarImpl};
30use risingwave_common::util::iter_util::ZipEqFast;
31use sea_schema::mysql::def::{ColumnDefault, ColumnKey, ColumnType, NumericAttr};
32use sea_schema::mysql::discovery::SchemaDiscovery;
33use sea_schema::mysql::query::SchemaQueryBuilder;
34use sea_schema::sea_query::{Alias, IntoIden};
35use serde::{Deserialize, Serialize};
36use sqlx::MySqlPool;
37use sqlx::mysql::MySqlConnectOptions;
38use thiserror_ext::AsReport;
39
40use crate::connector_common::SslMode;
41pub use crate::connector_common::SslMode as MySqlSslMode;
43use crate::error::{ConnectorError, ConnectorResult};
44use crate::source::CdcTableSnapshotSplit;
45use crate::source::cdc::external::{
46 CdcOffset, CdcOffsetParseFunc, CdcTableSnapshotSplitOption, DebeziumOffset,
47 ExternalTableConfig, ExternalTableReader, SchemaTableName, mysql_row_to_owned_row,
48};
49
50pub fn build_mysql_connection_pool(
67 host: &str,
68 port: u16,
69 username: &str,
70 password: &str,
71 database: &str,
72 ssl_mode: SslMode,
73) -> mysql_async::Pool {
74 let mut opts_builder = mysql_async::OptsBuilder::default()
75 .user(Some(username))
76 .pass(Some(password))
77 .ip_or_hostname(host)
78 .tcp_port(port)
79 .db_name(Some(database));
80
81 opts_builder = match ssl_mode {
82 SslMode::Disabled | SslMode::Preferred => opts_builder.ssl_opts(None),
83 SslMode::Required | SslMode::VerifyCa | SslMode::VerifyFull => {
85 let ssl_without_verify = mysql_async::SslOpts::default()
86 .with_danger_accept_invalid_certs(true)
87 .with_danger_skip_domain_validation(true);
88 opts_builder.ssl_opts(Some(ssl_without_verify))
89 }
90 };
91
92 mysql_async::Pool::new(opts_builder)
93}
94
95#[derive(Debug, Clone, Default, PartialEq, PartialOrd, Serialize, Deserialize)]
96pub struct MySqlOffset {
97 pub filename: String,
98 pub position: u64,
99}
100
101impl MySqlOffset {
102 pub fn new(filename: String, position: u64) -> Self {
103 Self { filename, position }
104 }
105}
106
107impl MySqlOffset {
108 pub fn parse_debezium_offset(offset: &str) -> ConnectorResult<Self> {
109 let dbz_offset: DebeziumOffset = serde_json::from_str(offset)
110 .with_context(|| format!("invalid upstream offset: {}", offset))?;
111
112 Ok(Self {
113 filename: dbz_offset
114 .source_offset
115 .file
116 .context("binlog file not found in offset")?,
117 position: dbz_offset
118 .source_offset
119 .pos
120 .context("binlog position not found in offset")?,
121 })
122 }
123}
124
125pub struct MySqlExternalTable {
126 column_descs: Vec<ColumnDesc>,
127 pk_names: Vec<String>,
128}
129
130impl MySqlExternalTable {
131 pub async fn connect(config: ExternalTableConfig) -> ConnectorResult<Self> {
132 tracing::debug!("connect to mysql");
133 let options = MySqlConnectOptions::new()
134 .username(&config.username)
135 .password(&config.password)
136 .host(&config.host)
137 .port(config.port.parse::<u16>().unwrap())
138 .database(&config.database)
139 .ssl_mode(match config.ssl_mode {
140 SslMode::Disabled => sqlx::mysql::MySqlSslMode::Disabled,
141 SslMode::Preferred => sqlx::mysql::MySqlSslMode::Preferred,
142 SslMode::Required => sqlx::mysql::MySqlSslMode::Required,
143 _ => {
144 return Err(anyhow!("unsupported SSL mode").into());
145 }
146 });
147
148 let connection = MySqlPool::connect_with(options).await?;
149 let mut schema_discovery = SchemaDiscovery::new(connection, config.database.as_str());
150
151 let system_info = schema_discovery.discover_system().await?;
153 schema_discovery.query = SchemaQueryBuilder::new(system_info.clone());
154 let schema = Alias::new(config.database.as_str()).into_iden();
155 let table = Alias::new(config.table.as_str()).into_iden();
156 let columns = schema_discovery
157 .discover_columns(schema, table, &system_info)
158 .await?;
159 let mut column_descs = vec![];
160 let mut pk_names = vec![];
161 for col in columns {
162 let data_type = mysql_type_to_rw_type(&col.col_type)?;
163 let col_name = col.name.to_lowercase();
165 let column_desc = if let Some(default) = col.default {
166 let snapshot_value = derive_default_value(default.clone(), &data_type)
167 .unwrap_or_else(|e| {
168 tracing::warn!(
169 column = col_name,
170 ?default,
171 %data_type,
172 error = %e.as_report(),
173 "failed to derive column default value, fallback to `NULL`",
174 );
175 None
176 });
177
178 ColumnDesc::named_with_default_value(
179 col_name.clone(),
180 ColumnId::placeholder(),
181 data_type.clone(),
182 snapshot_value,
183 )
184 } else {
185 ColumnDesc::named(col_name.clone(), ColumnId::placeholder(), data_type)
186 };
187
188 column_descs.push(column_desc);
189 if matches!(col.key, ColumnKey::Primary) {
190 pk_names.push(col_name);
191 }
192 }
193
194 if pk_names.is_empty() {
195 return Err(anyhow!("MySQL table doesn't define the primary key").into());
196 }
197 Ok(Self {
198 column_descs,
199 pk_names,
200 })
201 }
202
203 pub fn column_descs(&self) -> &Vec<ColumnDesc> {
204 &self.column_descs
205 }
206
207 pub fn pk_names(&self) -> &Vec<String> {
208 &self.pk_names
209 }
210}
211
212fn derive_default_value(default: ColumnDefault, data_type: &DataType) -> ConnectorResult<Datum> {
213 let datum = match default {
214 ColumnDefault::Null => None,
215 ColumnDefault::Int(val) => match data_type {
216 DataType::Int16 => Some(ScalarImpl::Int16(val as _)),
217 DataType::Int32 => Some(ScalarImpl::Int32(val as _)),
218 DataType::Int64 => Some(ScalarImpl::Int64(val)),
219 DataType::Varchar => {
220 Some(ScalarImpl::from(val.to_string()))
222 }
223 _ => bail!("unexpected default value type for integer"),
224 },
225 ColumnDefault::Real(val) => match data_type {
226 DataType::Float32 => Some(ScalarImpl::Float32(F32::from(val as f32))),
227 DataType::Float64 => Some(ScalarImpl::Float64(val.into())),
228 DataType::Decimal => Some(ScalarImpl::Decimal(
229 Decimal::try_from(val).context("failed to convert default value to decimal")?,
230 )),
231 _ => bail!("unexpected default value type for real"),
232 },
233 ColumnDefault::String(mut val) => {
234 if data_type == &DataType::Timestamptz {
237 val = timestamp_val_to_timestamptz(val.as_str())?;
238 }
239 Some(ScalarImpl::from_text(val.as_str(), data_type).map_err(|e| anyhow!(e)).context(
240 "failed to parse mysql default value expression, only constant is supported",
241 )?)
242 }
243 ColumnDefault::CurrentTimestamp | ColumnDefault::CustomExpr(_) => {
244 bail!("MySQL CURRENT_TIMESTAMP and custom expression default value not supported")
245 }
246 };
247 Ok(datum)
248}
249
250pub fn timestamp_val_to_timestamptz(value_text: &str) -> ConnectorResult<String> {
251 let format = "%Y-%m-%d %H:%M:%S";
252 let naive_datetime = NaiveDateTime::parse_from_str(value_text, format)
253 .map_err(|err| anyhow!("failed to parse mysql timestamp value").context(err))?;
254 let postgres_timestamptz: DateTime<chrono::Utc> =
255 DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
256 Ok(postgres_timestamptz
257 .format("%Y-%m-%d %H:%M:%S%:z")
258 .to_string())
259}
260
261pub fn type_name_to_mysql_type(ty_name: &str) -> Option<ColumnType> {
262 let ty = ty_name.trim().to_lowercase();
265 let tokens = ty
266 .split(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | ','))
267 .filter(|token| !token.is_empty())
268 .collect_vec();
269 let base = tokens.first().copied().unwrap_or_default();
270 let second = tokens.get(1).copied();
271 let is_unsigned = tokens.contains(&"unsigned");
272 let is_zero_fill = tokens.contains(&"zerofill");
273
274 let make_numeric_attr = || {
275 let mut attr = NumericAttr::default();
276 if is_unsigned {
277 attr.unsigned = Some(true);
278 }
279 if is_zero_fill {
280 attr.zero_fill = Some(true);
281 }
282 attr
283 };
284
285 match (base, second) {
286 ("character", Some("varying")) => return Some(ColumnType::Varchar(Default::default())),
287 ("double", Some("precision")) => return Some(ColumnType::Double(make_numeric_attr())),
288 ("long", Some("varchar")) => return Some(ColumnType::MediumText(Default::default())),
289 ("long", Some("varbinary")) => return Some(ColumnType::MediumBlob),
290 _ => {}
291 }
292
293 match base {
294 "serial" => Some(ColumnType::Serial),
295 "bit" => Some(ColumnType::Bit(make_numeric_attr())),
296 "tinyint" | "int1" => Some(ColumnType::TinyInt(make_numeric_attr())),
297 "bool" | "boolean" => Some(ColumnType::Bool),
298 "smallint" | "int2" => Some(ColumnType::SmallInt(make_numeric_attr())),
299 "mediumint" | "middleint" | "int3" => Some(ColumnType::MediumInt(make_numeric_attr())),
300 "int" | "integer" | "int4" => Some(ColumnType::Int(make_numeric_attr())),
301 "bigint" | "int8" => Some(ColumnType::BigInt(make_numeric_attr())),
302 "decimal" | "dec" | "fixed" | "numeric" => Some(ColumnType::Decimal(make_numeric_attr())),
303 "float" | "float4" => Some(ColumnType::Float(make_numeric_attr())),
304 "double" | "float8" | "real" => Some(ColumnType::Double(make_numeric_attr())),
305 "time" => Some(ColumnType::Time(Default::default())),
306 "datetime" => Some(ColumnType::DateTime(Default::default())),
307 "timestamp" => Some(ColumnType::Timestamp(Default::default())),
308 "year" => Some(ColumnType::Year),
309 "char" | "character" => Some(ColumnType::Char(Default::default())),
310 "nchar" => Some(ColumnType::NChar(Default::default())),
311 "varchar" => Some(ColumnType::Varchar(Default::default())),
312 "nvarchar" => Some(ColumnType::NVarchar(Default::default())),
313 "binary" => Some(ColumnType::Binary(Default::default())),
314 "varbinary" => Some(ColumnType::Varbinary(Default::default())),
315 "text" => Some(ColumnType::Text(Default::default())),
316 "tinytext" => Some(ColumnType::TinyText(Default::default())),
317 "mediumtext" => Some(ColumnType::MediumText(Default::default())),
318 "longtext" => Some(ColumnType::LongText(Default::default())),
319 "blob" => Some(ColumnType::Blob(Default::default())),
320 "tinyblob" => Some(ColumnType::TinyBlob),
321 "mediumblob" => Some(ColumnType::MediumBlob),
322 "longblob" => Some(ColumnType::LongBlob),
323 "enum" => Some(ColumnType::Enum(Default::default())),
324 "set" => Some(ColumnType::Set(Default::default())),
325 "json" => Some(ColumnType::Json),
326 "date" => Some(ColumnType::Date),
327 "geometry" => Some(ColumnType::Geometry(Default::default())),
328 "point" => Some(ColumnType::Point(Default::default())),
329 "linestring" => Some(ColumnType::LineString(Default::default())),
330 "polygon" => Some(ColumnType::Polygon(Default::default())),
331 "multipoint" => Some(ColumnType::MultiPoint(Default::default())),
332 "multilinestring" => Some(ColumnType::MultiLineString(Default::default())),
333 "multipolygon" => Some(ColumnType::MultiPolygon(Default::default())),
334 "geometrycollection" => Some(ColumnType::GeometryCollection(Default::default())),
335 _ => None,
336 }
337}
338
339fn mysql_type_is_unsigned_bigint(col_type: &ColumnType) -> bool {
340 match col_type {
341 ColumnType::Serial => true,
343 ColumnType::BigInt(attr) => attr.unsigned == Some(true),
344 _ => false,
345 }
346}
347
348pub fn mysql_type_to_rw_type(col_type: &ColumnType) -> ConnectorResult<DataType> {
349 let dtype = match col_type {
350 ColumnType::Serial => DataType::Int32,
351 ColumnType::Bit(attr) => {
352 if let Some(1) = attr.maximum {
353 DataType::Boolean
354 } else {
355 return Err(
356 anyhow!("BIT({}) type not supported", attr.maximum.unwrap_or(0)).into(),
357 );
358 }
359 }
360 ColumnType::TinyInt(_) => DataType::Int16,
362 ColumnType::SmallInt(attr) => {
363 if attr.unsigned == Some(true) {
364 DataType::Int32
365 } else {
366 DataType::Int16
367 }
368 }
369 ColumnType::Bool => DataType::Boolean,
370 ColumnType::MediumInt(_) => DataType::Int32,
371 ColumnType::Int(attr) => {
372 if attr.unsigned == Some(true) {
373 DataType::Int64
374 } else {
375 DataType::Int32
376 }
377 }
378 ColumnType::BigInt(attr) => {
379 if attr.unsigned == Some(true) {
380 DataType::Decimal
381 } else {
382 DataType::Int64
383 }
384 }
385 ColumnType::Decimal(_) => DataType::Decimal,
386 ColumnType::Float(_) => DataType::Float32,
387 ColumnType::Double(_) => DataType::Float64,
388 ColumnType::Date => DataType::Date,
389 ColumnType::Time(_) => DataType::Time,
390 ColumnType::DateTime(_) => DataType::Timestamp,
391 ColumnType::Timestamp(_) => DataType::Timestamptz,
392 ColumnType::Year => DataType::Int32,
393 ColumnType::Char(_)
394 | ColumnType::NChar(_)
395 | ColumnType::Varchar(_)
396 | ColumnType::NVarchar(_) => DataType::Varchar,
397 ColumnType::Binary(_) | ColumnType::Varbinary(_) => DataType::Bytea,
398 ColumnType::Text(_)
399 | ColumnType::TinyText(_)
400 | ColumnType::MediumText(_)
401 | ColumnType::LongText(_) => DataType::Varchar,
402 ColumnType::Blob(_)
403 | ColumnType::TinyBlob
404 | ColumnType::MediumBlob
405 | ColumnType::LongBlob => DataType::Bytea,
406 ColumnType::Enum(_) => DataType::Varchar,
407 ColumnType::Json => DataType::Jsonb,
408 ColumnType::Set(_) => {
409 return Err(anyhow!("SET type not supported").into());
410 }
411 ColumnType::Geometry(_) => {
412 return Err(anyhow!("GEOMETRY type not supported").into());
413 }
414 ColumnType::Point(_) => {
415 return Err(anyhow!("POINT type not supported").into());
416 }
417 ColumnType::LineString(_) => {
418 return Err(anyhow!("LINE string type not supported").into());
419 }
420 ColumnType::Polygon(_) => {
421 return Err(anyhow!("POLYGON type not supported").into());
422 }
423 ColumnType::MultiPoint(_) => {
424 return Err(anyhow!("MULTI POINT type not supported").into());
425 }
426 ColumnType::MultiLineString(_) => {
427 return Err(anyhow!("MULTI LINE STRING type not supported").into());
428 }
429 ColumnType::MultiPolygon(_) => {
430 return Err(anyhow!("MULTI POLYGON type not supported").into());
431 }
432 ColumnType::GeometryCollection(_) => {
433 return Err(anyhow!("GEOMETRY COLLECTION type not supported").into());
434 }
435 ColumnType::Unknown(_) => {
436 return Err(anyhow!("Unknown MySQL data type").into());
437 }
438 };
439
440 Ok(dtype)
441}
442
443pub struct MySqlExternalTableReader {
444 rw_schema: Schema,
445 field_names: String,
446 pool: mysql_async::Pool,
447 upstream_mysql_pk_infos: Vec<(String, ColumnType)>, mysql_version: (u8, u8),
449 is_mariadb: bool,
450}
451
452impl ExternalTableReader for MySqlExternalTableReader {
453 async fn current_cdc_offset(&self) -> ConnectorResult<CdcOffset> {
454 let mut conn = self.pool.get_conn().await?;
455
456 let sql = if !self.is_mariadb && self.is_mysql_8_4_or_later() {
458 "SHOW BINARY LOG STATUS"
459 } else {
460 "SHOW MASTER STATUS"
461 };
462
463 tracing::debug!(
464 "Using SQL command: {} for MySQL version {}.{} (is_mariadb={})",
465 sql,
466 self.mysql_version.0,
467 self.mysql_version.1,
468 self.is_mariadb
469 );
470 let mut rs = conn.query::<mysql_async::Row, _>(sql).await?;
471 let row = Itertools::exactly_one(rs.iter_mut())
472 .ok()
473 .context("expect exactly one row when reading binlog offset")?;
474 drop(conn);
475 Ok(CdcOffset::MySql(MySqlOffset {
476 filename: row.take("File").unwrap(),
477 position: row.take("Position").unwrap(),
478 }))
479 }
480
481 fn snapshot_read(
482 &self,
483 table_name: SchemaTableName,
484 start_pk: Option<OwnedRow>,
485 primary_keys: Vec<String>,
486 limit: u32,
487 ) -> BoxStream<'_, ConnectorResult<OwnedRow>> {
488 self.snapshot_read_inner(table_name, start_pk, primary_keys, limit)
489 }
490
491 async fn disconnect(self) -> ConnectorResult<()> {
492 self.pool.disconnect().await.map_err(|e| e.into())
493 }
494
495 fn get_parallel_cdc_splits(
496 &self,
497 _options: CdcTableSnapshotSplitOption,
498 ) -> BoxStream<'_, ConnectorResult<CdcTableSnapshotSplit>> {
499 stream::empty::<ConnectorResult<CdcTableSnapshotSplit>>().boxed()
501 }
502
503 fn split_snapshot_read(
504 &self,
505 _table_name: SchemaTableName,
506 _left: OwnedRow,
507 _right: OwnedRow,
508 _split_columns: Vec<Field>,
509 ) -> BoxStream<'_, ConnectorResult<OwnedRow>> {
510 todo!("implement MySQL CDC parallelized backfill")
511 }
512}
513
514impl MySqlExternalTableReader {
515 async fn get_mysql_version(pool: &mysql_async::Pool) -> ConnectorResult<(u8, u8, bool)> {
517 let mut conn = pool.get_conn().await?;
518 let result: Option<String> = conn.query_first("SELECT VERSION()").await?;
519
520 if let Some(version_str) = result {
521 let parts: Vec<&str> = version_str.split('.').collect();
522 if parts.len() >= 2 {
523 let major_version = parts[0]
524 .parse::<u8>()
525 .context("Failed to parse major version")?;
526 let minor_version = parts[1]
527 .parse::<u8>()
528 .context("Failed to parse minor version")?;
529 let is_mariadb = version_str.to_lowercase().contains("mariadb");
530 return Ok((major_version, minor_version, is_mariadb));
531 }
532 }
533 Err(anyhow!("Failed to get MySQL version").into())
534 }
535
536 fn is_mysql_8_4_or_later(&self) -> bool {
538 let (major, minor) = self.mysql_version;
539 major > 8 || (major == 8 && minor >= 4)
540 }
541
542 pub async fn new(config: ExternalTableConfig, rw_schema: Schema) -> ConnectorResult<Self> {
543 let database = config.database.clone();
544 let table = config.table.clone();
545 let pool = build_mysql_connection_pool(
546 &config.host,
547 config.port.parse::<u16>().unwrap(),
548 &config.username,
549 &config.password,
550 &config.database,
551 config.ssl_mode,
552 );
553
554 let field_names = rw_schema
555 .fields
556 .iter()
557 .filter(|f| f.name != CDC_OFFSET_COLUMN_NAME)
558 .map(|f| Self::quote_column(f.name.as_str()))
559 .join(",");
560
561 let upstream_mysql_pk_infos =
563 Self::query_upstream_pk_infos(&pool, &database, &table).await?;
564 let (major_version, minor_version, is_mariadb) = Self::get_mysql_version(&pool).await?;
566 let mysql_version = (major_version, minor_version);
567 tracing::info!(
568 "MySQL version detected: {}.{} (is_mariadb={})",
569 mysql_version.0,
570 mysql_version.1,
571 is_mariadb
572 );
573
574 Ok(Self {
575 rw_schema,
576 field_names,
577 pool,
578 upstream_mysql_pk_infos,
579 mysql_version,
580 is_mariadb,
581 })
582 }
583
584 pub fn get_normalized_table_name(table_name: &SchemaTableName) -> String {
585 format!("`{}`.`{}`", table_name.schema_name, table_name.table_name)
587 }
588
589 pub fn get_cdc_offset_parser() -> CdcOffsetParseFunc {
590 Box::new(move |offset| {
591 Ok(CdcOffset::MySql(MySqlOffset::parse_debezium_offset(
592 offset,
593 )?))
594 })
595 }
596
597 async fn query_upstream_pk_infos(
599 pool: &mysql_async::Pool,
600 database: &str,
601 table: &str,
602 ) -> ConnectorResult<Vec<(String, ColumnType)>> {
603 let mut conn = pool.get_conn().await?;
604
605 let sql = format!(
607 "SELECT COLUMN_NAME, COLUMN_TYPE
608 FROM INFORMATION_SCHEMA.COLUMNS
609 WHERE TABLE_SCHEMA = '{}'
610 AND TABLE_NAME = '{}'
611 AND COLUMN_KEY = 'PRI'
612 ORDER BY ORDINAL_POSITION",
613 database, table
614 );
615
616 let rs = conn.query::<mysql_async::Row, _>(sql).await?;
617
618 let mut column_infos = Vec::new();
619 for row in &rs {
620 let column_name: String = row.get(0).unwrap();
621 let column_type: String = row.get(1).unwrap();
622 let column_type =
623 type_name_to_mysql_type(&column_type).unwrap_or(ColumnType::Unknown(column_type));
624 column_infos.push((column_name, column_type));
625 }
626
627 drop(conn);
628
629 Ok(column_infos)
630 }
631
632 fn needs_unsigned_i64_compare(&self, column_name: &str) -> ConnectorResult<bool> {
639 self.upstream_mysql_pk_infos
640 .iter()
641 .find(|(col_name, _)| col_name.eq_ignore_ascii_case(column_name))
642 .map(|(_, col_type)| mysql_type_is_unsigned_bigint(col_type))
643 .ok_or_else(|| {
644 anyhow!(
645 "primary key column `{column_name}` not found in upstream MySQL primary key info"
646 )
647 .into()
648 })
649 }
650
651 pub(crate) fn pk_column_unsigned_i64_compare_flags(
653 &self,
654 pk_names: &[String],
655 ) -> ConnectorResult<Vec<bool>> {
656 pk_names
657 .iter()
658 .map(|name| self.needs_unsigned_i64_compare(name))
659 .collect()
660 }
661
662 fn convert_negative_to_unsigned(&self, negative_val: i64) -> u64 {
664 negative_val as u64
665 }
666
667 #[try_stream(boxed, ok = OwnedRow, error = ConnectorError)]
668 async fn snapshot_read_inner(
669 &self,
670 table_name: SchemaTableName,
671 start_pk_row: Option<OwnedRow>,
672 primary_keys: Vec<String>,
673 limit: u32,
674 ) {
675 let order_key = primary_keys
676 .iter()
677 .map(|col| Self::quote_column(col))
678 .join(",");
679 let sql = if start_pk_row.is_none() {
680 format!(
681 "SELECT {} FROM {} ORDER BY {} LIMIT {limit}",
682 self.field_names,
683 Self::get_normalized_table_name(&table_name),
684 order_key,
685 )
686 } else {
687 let filter_expr = Self::filter_expression(&primary_keys);
688 format!(
689 "SELECT {} FROM {} WHERE {} ORDER BY {} LIMIT {limit}",
690 self.field_names,
691 Self::get_normalized_table_name(&table_name),
692 filter_expr,
693 order_key,
694 )
695 };
696 let mut conn = self.pool.get_conn().await?;
697 conn.exec_drop("SET time_zone = \"+00:00\"", ()).await?;
699
700 if let Some(start_pk_row) = start_pk_row {
701 let field_map = self
702 .rw_schema
703 .fields
704 .iter()
705 .map(|f| (f.name.as_str(), f.data_type.clone()))
706 .collect::<HashMap<_, _>>();
707
708 let params: Vec<_> = primary_keys
710 .iter()
711 .zip_eq_fast(start_pk_row.into_iter())
712 .map(|(pk, datum)| {
713 if let Some(value) = datum {
714 let ty = field_map.get(pk.as_str()).unwrap();
715 let val = match ty {
716 DataType::Boolean => Value::from(value.into_bool()),
717 DataType::Int16 => Value::from(value.into_int16()),
718 DataType::Int32 => Value::from(value.into_int32()),
719 DataType::Int64 => {
720 let int64_val = value.into_int64();
721 if int64_val < 0 && self.needs_unsigned_i64_compare(pk.as_str())? {
722 Value::from(self.convert_negative_to_unsigned(int64_val))
723 } else {
724 Value::from(int64_val)
725 }
726 }
727 DataType::Float32 => Value::from(value.into_float32().into_inner()),
728 DataType::Float64 => Value::from(value.into_float64().into_inner()),
729 DataType::Varchar => Value::from(String::from(value.into_utf8())),
730 DataType::Date => Value::from(value.into_date().0),
731 DataType::Time => Value::from(value.into_time().0),
732 DataType::Timestamp => Value::from(value.into_timestamp().0),
733 DataType::Decimal => Value::from(value.into_decimal().to_string()),
734 DataType::Timestamptz => {
735 let ts = value.into_timestamptz();
738 let datetime_utc = ts.to_datetime_utc();
739 let naive_datetime = datetime_utc.naive_utc();
740 Value::from(naive_datetime)
741 }
742 _ => bail!("unsupported primary key data type: {}", ty),
743 };
744 ConnectorResult::Ok((pk.to_lowercase(), val))
745 } else {
746 bail!("primary key {} cannot be null", pk);
747 }
748 })
749 .try_collect::<_, _, ConnectorError>()?;
750
751 tracing::debug!("snapshot read params: {:?}", ¶ms);
752 let rs_stream = sql
753 .with(Params::from(params))
754 .stream::<mysql_async::Row, _>(&mut conn)
755 .await?;
756
757 let row_stream = rs_stream.map(|row| {
758 let mut row = row?;
760 Ok::<_, ConnectorError>(mysql_row_to_owned_row(&mut row, &self.rw_schema))
761 });
762 pin_mut!(row_stream);
763 #[for_await]
764 for row in row_stream {
765 let row = row?;
766 yield row;
767 }
768 } else {
769 let rs_stream = sql.stream::<mysql_async::Row, _>(&mut conn).await?;
770 let row_stream = rs_stream.map(|row| {
771 let mut row = row?;
773 Ok::<_, ConnectorError>(mysql_row_to_owned_row(&mut row, &self.rw_schema))
774 });
775 pin_mut!(row_stream);
776 #[for_await]
777 for row in row_stream {
778 let row = row?;
779 yield row;
780 }
781 }
782 drop(conn);
783 }
784
785 fn filter_expression(columns: &[String]) -> String {
789 let mut conditions = vec![];
790 conditions.push(format!(
792 "({} > :{})",
793 Self::quote_column(&columns[0]),
794 columns[0].to_lowercase()
795 ));
796 for i in 2..=columns.len() {
797 let mut condition = String::new();
799 for (j, col) in columns.iter().enumerate().take(i - 1) {
800 if j == 0 {
801 condition.push_str(&format!(
802 "({} = :{})",
803 Self::quote_column(col),
804 col.to_lowercase()
805 ));
806 } else {
807 condition.push_str(&format!(
808 " AND ({} = :{})",
809 Self::quote_column(col),
810 col.to_lowercase()
811 ));
812 }
813 }
814 condition.push_str(&format!(
816 " AND ({} > :{})",
817 Self::quote_column(&columns[i - 1]),
818 columns[i - 1].to_lowercase()
819 ));
820 conditions.push(format!("({})", condition));
821 }
822 if columns.len() > 1 {
823 conditions.join(" OR ")
824 } else {
825 conditions.join("")
826 }
827 }
828
829 fn quote_column(column: &str) -> String {
830 format!("`{}`", column)
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use std::collections::HashMap;
837
838 use futures::pin_mut;
839 use futures_async_stream::for_await;
840 use maplit::{convert_args, hashmap};
841 use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, Schema};
842 use risingwave_common::types::DataType;
843 use sea_schema::mysql::def::ColumnType;
844
845 use super::{mysql_type_is_unsigned_bigint, type_name_to_mysql_type};
846 use crate::source::cdc::external::mysql::MySqlExternalTable;
847 use crate::source::cdc::external::{
848 CdcOffset, ExternalTableConfig, ExternalTableReader, MySqlExternalTableReader, MySqlOffset,
849 SchemaTableName,
850 };
851
852 fn parse_mysql_type_name(ty_name: &str) -> ColumnType {
853 type_name_to_mysql_type(ty_name).unwrap()
854 }
855
856 #[test]
857 fn test_mysql_unsigned_bigint_type_detection() {
858 for ty_name in [
859 "SERIAL",
860 "BIGINT UNSIGNED",
861 "BIGINT(20) UNSIGNED",
862 "BIGINT UNSIGNED ZEROFILL",
863 "INT8 UNSIGNED",
864 ] {
865 assert!(
866 mysql_type_is_unsigned_bigint(&parse_mysql_type_name(ty_name)),
867 "{ty_name}"
868 );
869 }
870
871 for ty_name in [
872 "BIGINT",
873 "INTEGER UNSIGNED",
874 "INT4 UNSIGNED",
875 "MEDIUMINT UNSIGNED",
876 "DECIMAL UNSIGNED",
877 "FLOAT8 UNSIGNED",
878 ] {
879 assert!(
880 !mysql_type_is_unsigned_bigint(&parse_mysql_type_name(ty_name)),
881 "{ty_name}"
882 );
883 }
884 }
885
886 #[test]
887 fn test_mysql_type_aliases() {
888 assert!(matches!(
889 parse_mysql_type_name("INTEGER UNSIGNED"),
890 ColumnType::Int(attr) if attr.unsigned == Some(true)
891 ));
892 assert!(matches!(
893 parse_mysql_type_name("INT1 UNSIGNED"),
894 ColumnType::TinyInt(attr) if attr.unsigned == Some(true)
895 ));
896 assert!(matches!(
897 parse_mysql_type_name("INT2 UNSIGNED"),
898 ColumnType::SmallInt(attr) if attr.unsigned == Some(true)
899 ));
900 assert!(matches!(
901 parse_mysql_type_name("INT3 UNSIGNED"),
902 ColumnType::MediumInt(attr) if attr.unsigned == Some(true)
903 ));
904 assert!(matches!(
905 parse_mysql_type_name("INT4 UNSIGNED"),
906 ColumnType::Int(attr) if attr.unsigned == Some(true)
907 ));
908 assert!(matches!(
909 parse_mysql_type_name("INT8 UNSIGNED"),
910 ColumnType::BigInt(attr) if attr.unsigned == Some(true)
911 ));
912 assert!(matches!(
913 parse_mysql_type_name("MIDDLEINT"),
914 ColumnType::MediumInt(_)
915 ));
916 assert!(matches!(
917 parse_mysql_type_name("NUMERIC"),
918 ColumnType::Decimal(_)
919 ));
920 assert!(matches!(
921 parse_mysql_type_name("CHARACTER VARYING(64)"),
922 ColumnType::Varchar(_)
923 ));
924 assert!(matches!(
925 parse_mysql_type_name("LONG VARBINARY"),
926 ColumnType::MediumBlob
927 ));
928 }
929
930 #[test]
931 fn test_mysql_serial_maps_as_unsigned_bigint() {
932 let col_type = parse_mysql_type_name("SERIAL");
933 assert!(mysql_type_is_unsigned_bigint(&col_type));
934 }
935
936 #[ignore]
937 #[tokio::test]
938 async fn test_mysql_schema() {
939 let config = ExternalTableConfig {
940 connector: "mysql-cdc".to_owned(),
941 host: "localhost".to_owned(),
942 port: "8306".to_owned(),
943 username: "root".to_owned(),
944 password: "123456".to_owned(),
945 database: "mydb".to_owned(),
946 schema: "".to_owned(),
947 table: "part".to_owned(),
948 ssl_mode: Default::default(),
949 ssl_root_cert: None,
950 encrypt: "false".to_owned(),
951 };
952
953 let table = MySqlExternalTable::connect(config).await.unwrap();
954 println!("columns: {:?}", table.column_descs);
955 println!("primary keys: {:?}", table.pk_names);
956 }
957
958 #[test]
959 fn test_mysql_filter_expr() {
960 let cols = vec!["id".to_owned()];
961 let expr = MySqlExternalTableReader::filter_expression(&cols);
962 assert_eq!(expr, "(`id` > :id)");
963
964 let cols = vec!["aa".to_owned(), "bb".to_owned(), "cc".to_owned()];
965 let expr = MySqlExternalTableReader::filter_expression(&cols);
966 assert_eq!(
967 expr,
968 "(`aa` > :aa) OR ((`aa` = :aa) AND (`bb` > :bb)) OR ((`aa` = :aa) AND (`bb` = :bb) AND (`cc` > :cc))"
969 );
970 }
971
972 #[test]
973 fn test_mysql_binlog_offset() {
974 let off0_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000001", "pos": 105622, "snapshot": true }, "isHeartbeat": false }"#;
975 let off1_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000007", "pos": 1062363217, "snapshot": true }, "isHeartbeat": false }"#;
976 let off2_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000007", "pos": 659687560, "snapshot": true }, "isHeartbeat": false }"#;
977 let off3_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000008", "pos": 7665875, "snapshot": true }, "isHeartbeat": false }"#;
978 let off4_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000008", "pos": 7665875, "snapshot": true }, "isHeartbeat": false }"#;
979
980 let off0 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off0_str).unwrap());
981 let off1 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off1_str).unwrap());
982 let off2 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off2_str).unwrap());
983 let off3 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off3_str).unwrap());
984 let off4 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off4_str).unwrap());
985
986 assert!(off0 <= off1);
987 assert!(off1 > off2);
988 assert!(off2 < off3);
989 assert_eq!(off3, off4);
990 }
991
992 #[ignore]
994 #[tokio::test]
995 async fn test_mysql_table_reader() {
996 let columns = [
997 ColumnDesc::named("v1", ColumnId::new(1), DataType::Int32),
998 ColumnDesc::named("v2", ColumnId::new(2), DataType::Decimal),
999 ColumnDesc::named("v3", ColumnId::new(3), DataType::Varchar),
1000 ColumnDesc::named("v4", ColumnId::new(4), DataType::Date),
1001 ];
1002 let rw_schema = Schema {
1003 fields: columns.iter().map(Field::from).collect(),
1004 };
1005 let props: HashMap<String, String> = convert_args!(hashmap!(
1006 "hostname" => "localhost",
1007 "port" => "8306",
1008 "username" => "root",
1009 "password" => "123456",
1010 "database.name" => "mytest",
1011 "table.name" => "t1"));
1012
1013 let config =
1014 serde_json::from_value::<ExternalTableConfig>(serde_json::to_value(props).unwrap())
1015 .unwrap();
1016 let reader = MySqlExternalTableReader::new(config, rw_schema)
1017 .await
1018 .unwrap();
1019 let offset = reader.current_cdc_offset().await.unwrap();
1020 println!("BinlogOffset: {:?}", offset);
1021
1022 let off0_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000001", "pos": 105622, "snapshot": true }, "isHeartbeat": false }"#;
1023 let parser = MySqlExternalTableReader::get_cdc_offset_parser();
1024 println!("parsed offset: {:?}", parser(off0_str).unwrap());
1025 let table_name = SchemaTableName {
1026 schema_name: "mytest".to_owned(),
1027 table_name: "t1".to_owned(),
1028 };
1029
1030 let stream = reader.snapshot_read(table_name, None, vec!["v1".to_owned()], 1000);
1031 pin_mut!(stream);
1032 #[for_await]
1033 for row in stream {
1034 println!("OwnedRow: {:?}", row);
1035 }
1036 }
1037}