risingwave_connector/source/cdc/external/
mysql.rs

1// Copyright 2025 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::HashMap;
16
17use anyhow::{Context, anyhow};
18use chrono::{DateTime, NaiveDateTime};
19use futures::stream::BoxStream;
20use futures::{StreamExt, pin_mut};
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, 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};
32use sea_schema::mysql::discovery::SchemaDiscovery;
33use sea_schema::mysql::query::SchemaQueryBuilder;
34use sea_schema::sea_query::{Alias, IntoIden};
35use serde_derive::{Deserialize, Serialize};
36use sqlx::MySqlPool;
37use sqlx::mysql::MySqlConnectOptions;
38use thiserror_ext::AsReport;
39
40use crate::error::{ConnectorError, ConnectorResult};
41use crate::source::cdc::external::{
42    CdcOffset, CdcOffsetParseFunc, DebeziumOffset, ExternalTableConfig, ExternalTableReader,
43    SchemaTableName, SslMode, mysql_row_to_owned_row,
44};
45
46#[derive(Debug, Clone, Default, PartialEq, PartialOrd, Serialize, Deserialize)]
47pub struct MySqlOffset {
48    pub filename: String,
49    pub position: u64,
50}
51
52impl MySqlOffset {
53    pub fn new(filename: String, position: u64) -> Self {
54        Self { filename, position }
55    }
56}
57
58impl MySqlOffset {
59    pub fn parse_debezium_offset(offset: &str) -> ConnectorResult<Self> {
60        let dbz_offset: DebeziumOffset = serde_json::from_str(offset)
61            .with_context(|| format!("invalid upstream offset: {}", offset))?;
62
63        Ok(Self {
64            filename: dbz_offset
65                .source_offset
66                .file
67                .context("binlog file not found in offset")?,
68            position: dbz_offset
69                .source_offset
70                .pos
71                .context("binlog position not found in offset")?,
72        })
73    }
74}
75
76pub struct MySqlExternalTable {
77    column_descs: Vec<ColumnDesc>,
78    pk_names: Vec<String>,
79}
80
81impl MySqlExternalTable {
82    pub async fn connect(config: ExternalTableConfig) -> ConnectorResult<Self> {
83        tracing::debug!("connect to mysql");
84        let options = MySqlConnectOptions::new()
85            .username(&config.username)
86            .password(&config.password)
87            .host(&config.host)
88            .port(config.port.parse::<u16>().unwrap())
89            .database(&config.database)
90            .ssl_mode(match config.ssl_mode {
91                SslMode::Disabled | SslMode::Preferred => sqlx::mysql::MySqlSslMode::Disabled,
92                SslMode::Required => sqlx::mysql::MySqlSslMode::Required,
93                _ => {
94                    return Err(anyhow!("unsupported SSL mode").into());
95                }
96            });
97
98        let connection = MySqlPool::connect_with(options).await?;
99        let mut schema_discovery = SchemaDiscovery::new(connection, config.database.as_str());
100
101        // discover system version first
102        let system_info = schema_discovery.discover_system().await?;
103        schema_discovery.query = SchemaQueryBuilder::new(system_info.clone());
104
105        let schema = Alias::new(config.database.as_str()).into_iden();
106        let table = Alias::new(config.table.as_str()).into_iden();
107        let columns = schema_discovery
108            .discover_columns(schema, table, &system_info)
109            .await?;
110
111        let mut column_descs = vec![];
112        let mut pk_names = vec![];
113        for col in columns {
114            let data_type = mysql_type_to_rw_type(&col.col_type)?;
115            // column name in mysql is case-insensitive, convert to lowercase
116            let col_name = col.name.to_lowercase();
117            let column_desc = if let Some(default) = col.default {
118                let snapshot_value = derive_default_value(default.clone(), &data_type)
119                    .unwrap_or_else(|e| {
120                        tracing::warn!(
121                            column = col_name,
122                            ?default,
123                            %data_type,
124                            error = %e.as_report(),
125                            "failed to derive column default value, fallback to `NULL`",
126                        );
127                        None
128                    });
129
130                ColumnDesc::named_with_default_value(
131                    col_name.clone(),
132                    ColumnId::placeholder(),
133                    data_type.clone(),
134                    snapshot_value,
135                )
136            } else {
137                ColumnDesc::named(col_name.clone(), ColumnId::placeholder(), data_type)
138            };
139
140            column_descs.push(column_desc);
141            if matches!(col.key, ColumnKey::Primary) {
142                pk_names.push(col_name);
143            }
144        }
145
146        if pk_names.is_empty() {
147            return Err(anyhow!("MySQL table doesn't define the primary key").into());
148        }
149
150        Ok(Self {
151            column_descs,
152            pk_names,
153        })
154    }
155
156    pub fn column_descs(&self) -> &Vec<ColumnDesc> {
157        &self.column_descs
158    }
159
160    pub fn pk_names(&self) -> &Vec<String> {
161        &self.pk_names
162    }
163}
164
165fn derive_default_value(default: ColumnDefault, data_type: &DataType) -> ConnectorResult<Datum> {
166    let datum = match default {
167        ColumnDefault::Null => None,
168        ColumnDefault::Int(val) => match data_type {
169            DataType::Int16 => Some(ScalarImpl::Int16(val as _)),
170            DataType::Int32 => Some(ScalarImpl::Int32(val as _)),
171            DataType::Int64 => Some(ScalarImpl::Int64(val)),
172            DataType::Varchar => {
173                // should be the Enum type which is mapped to Varchar
174                Some(ScalarImpl::from(val.to_string()))
175            }
176            _ => bail!("unexpected default value type for integer"),
177        },
178        ColumnDefault::Real(val) => match data_type {
179            DataType::Float32 => Some(ScalarImpl::Float32(F32::from(val as f32))),
180            DataType::Float64 => Some(ScalarImpl::Float64(val.into())),
181            DataType::Decimal => Some(ScalarImpl::Decimal(
182                Decimal::try_from(val).context("failed to convert default value to decimal")?,
183            )),
184            _ => bail!("unexpected default value type for real"),
185        },
186        ColumnDefault::String(mut val) => {
187            // mysql timestamp is mapped to timestamptz, we use UTC timezone to
188            // interpret its value
189            if data_type == &DataType::Timestamptz {
190                val = timestamp_val_to_timestamptz(val.as_str())?;
191            }
192            Some(ScalarImpl::from_text(val.as_str(), data_type).map_err(|e| anyhow!(e)).context(
193                "failed to parse mysql default value expression, only constant is supported",
194            )?)
195        }
196        ColumnDefault::CurrentTimestamp | ColumnDefault::CustomExpr(_) => {
197            bail!("MySQL CURRENT_TIMESTAMP and custom expression default value not supported")
198        }
199    };
200    Ok(datum)
201}
202
203pub fn timestamp_val_to_timestamptz(value_text: &str) -> ConnectorResult<String> {
204    let format = "%Y-%m-%d %H:%M:%S";
205    let naive_datetime = NaiveDateTime::parse_from_str(value_text, format)
206        .map_err(|err| anyhow!("failed to parse mysql timestamp value").context(err))?;
207    let postgres_timestamptz: DateTime<chrono::Utc> =
208        DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
209    Ok(postgres_timestamptz
210        .format("%Y-%m-%d %H:%M:%S%:z")
211        .to_string())
212}
213
214pub fn type_name_to_mysql_type(ty_name: &str) -> Option<ColumnType> {
215    macro_rules! column_type {
216        ($($name:literal => $variant:ident),* $(,)?) => {
217            match ty_name.to_lowercase().as_str() {
218                $(
219                    $name => Some(ColumnType::$variant(Default::default())),
220                )*
221                "json" => Some(ColumnType::Json),
222                "date" => Some(ColumnType::Date),
223                "bool" => Some(ColumnType::Bool),
224                "tinyblob" => Some(ColumnType::TinyBlob),
225                "mediumblob" => Some(ColumnType::MediumBlob),
226                "longblob" => Some(ColumnType::LongBlob),
227                _ => None,
228            }
229        };
230    }
231
232    column_type! {
233        "bit" => Bit,
234        "tinyint" => TinyInt,
235        "smallint" => SmallInt,
236        "mediumint" => MediumInt,
237        "int" => Int,
238        "bigint" => BigInt,
239        "decimal" => Decimal,
240        "float" => Float,
241        "double" => Double,
242        "time" => Time,
243        "datetime" => DateTime,
244        "timestamp" => Timestamp,
245        "char" => Char,
246        "nchar" => NChar,
247        "varchar" => Varchar,
248        "nvarchar" => NVarchar,
249        "binary" => Binary,
250        "varbinary" => Varbinary,
251        "text" => Text,
252        "tinytext" => TinyText,
253        "mediumtext" => MediumText,
254        "longtext" => LongText,
255        "blob" => Blob,
256        "enum" => Enum,
257        "set" => Set,
258        "geometry" => Geometry,
259        "point" => Point,
260        "linestring" => LineString,
261        "polygon" => Polygon,
262        "multipoint" => MultiPoint,
263        "multilinestring" => MultiLineString,
264        "multipolygon" => MultiPolygon,
265        "geometrycollection" => GeometryCollection,
266    }
267}
268
269pub fn mysql_type_to_rw_type(col_type: &ColumnType) -> ConnectorResult<DataType> {
270    let dtype = match col_type {
271        ColumnType::Serial => DataType::Int32,
272        ColumnType::Bit(attr) => {
273            if let Some(1) = attr.maximum {
274                DataType::Boolean
275            } else {
276                return Err(
277                    anyhow!("BIT({}) type not supported", attr.maximum.unwrap_or(0)).into(),
278                );
279            }
280        }
281        ColumnType::TinyInt(_) | ColumnType::SmallInt(_) => DataType::Int16,
282        ColumnType::Bool => DataType::Boolean,
283        ColumnType::MediumInt(_) => DataType::Int32,
284        ColumnType::Int(_) => DataType::Int32,
285        ColumnType::BigInt(_) => DataType::Int64,
286        ColumnType::Decimal(_) => DataType::Decimal,
287        ColumnType::Float(_) => DataType::Float32,
288        ColumnType::Double(_) => DataType::Float64,
289        ColumnType::Date => DataType::Date,
290        ColumnType::Time(_) => DataType::Time,
291        ColumnType::DateTime(_) => DataType::Timestamp,
292        ColumnType::Timestamp(_) => DataType::Timestamptz,
293        ColumnType::Year => DataType::Int32,
294        ColumnType::Char(_)
295        | ColumnType::NChar(_)
296        | ColumnType::Varchar(_)
297        | ColumnType::NVarchar(_) => DataType::Varchar,
298        ColumnType::Binary(_) | ColumnType::Varbinary(_) => DataType::Bytea,
299        ColumnType::Text(_)
300        | ColumnType::TinyText(_)
301        | ColumnType::MediumText(_)
302        | ColumnType::LongText(_) => DataType::Varchar,
303        ColumnType::Blob(_)
304        | ColumnType::TinyBlob
305        | ColumnType::MediumBlob
306        | ColumnType::LongBlob => DataType::Bytea,
307        ColumnType::Enum(_) => DataType::Varchar,
308        ColumnType::Json => DataType::Jsonb,
309        ColumnType::Set(_) => {
310            return Err(anyhow!("SET type not supported").into());
311        }
312        ColumnType::Geometry(_) => {
313            return Err(anyhow!("GEOMETRY type not supported").into());
314        }
315        ColumnType::Point(_) => {
316            return Err(anyhow!("POINT type not supported").into());
317        }
318        ColumnType::LineString(_) => {
319            return Err(anyhow!("LINE string type not supported").into());
320        }
321        ColumnType::Polygon(_) => {
322            return Err(anyhow!("POLYGON type not supported").into());
323        }
324        ColumnType::MultiPoint(_) => {
325            return Err(anyhow!("MULTI POINT type not supported").into());
326        }
327        ColumnType::MultiLineString(_) => {
328            return Err(anyhow!("MULTI LINE STRING type not supported").into());
329        }
330        ColumnType::MultiPolygon(_) => {
331            return Err(anyhow!("MULTI POLYGON type not supported").into());
332        }
333        ColumnType::GeometryCollection(_) => {
334            return Err(anyhow!("GEOMETRY COLLECTION type not supported").into());
335        }
336        ColumnType::Unknown(_) => {
337            return Err(anyhow!("Unknown MySQL data type").into());
338        }
339    };
340
341    Ok(dtype)
342}
343
344pub struct MySqlExternalTableReader {
345    rw_schema: Schema,
346    field_names: String,
347    // use mutex to provide shared mutable access to the connection
348    conn: tokio::sync::Mutex<mysql_async::Conn>,
349}
350
351impl ExternalTableReader for MySqlExternalTableReader {
352    async fn current_cdc_offset(&self) -> ConnectorResult<CdcOffset> {
353        let mut conn = self.conn.lock().await;
354
355        let sql = "SHOW MASTER STATUS".to_owned();
356        let mut rs = conn.query::<mysql_async::Row, _>(sql).await?;
357        let row = rs
358            .iter_mut()
359            .exactly_one()
360            .ok()
361            .context("expect exactly one row when reading binlog offset")?;
362
363        Ok(CdcOffset::MySql(MySqlOffset {
364            filename: row.take("File").unwrap(),
365            position: row.take("Position").unwrap(),
366        }))
367    }
368
369    fn snapshot_read(
370        &self,
371        table_name: SchemaTableName,
372        start_pk: Option<OwnedRow>,
373        primary_keys: Vec<String>,
374        limit: u32,
375    ) -> BoxStream<'_, ConnectorResult<OwnedRow>> {
376        self.snapshot_read_inner(table_name, start_pk, primary_keys, limit)
377    }
378}
379
380impl MySqlExternalTableReader {
381    pub async fn new(config: ExternalTableConfig, rw_schema: Schema) -> ConnectorResult<Self> {
382        let mut opts_builder = mysql_async::OptsBuilder::default()
383            .user(Some(config.username))
384            .pass(Some(config.password))
385            .ip_or_hostname(config.host)
386            .tcp_port(config.port.parse::<u16>().unwrap())
387            .db_name(Some(config.database));
388
389        opts_builder = match config.ssl_mode {
390            SslMode::Disabled | SslMode::Preferred => opts_builder.ssl_opts(None),
391            // verify-ca and verify-full are same as required for mysql now
392            SslMode::Required | SslMode::VerifyCa | SslMode::VerifyFull => {
393                let ssl_without_verify = mysql_async::SslOpts::default()
394                    .with_danger_accept_invalid_certs(true)
395                    .with_danger_skip_domain_validation(true);
396                opts_builder.ssl_opts(Some(ssl_without_verify))
397            }
398        };
399
400        let conn = mysql_async::Conn::new(mysql_async::Opts::from(opts_builder)).await?;
401
402        let field_names = rw_schema
403            .fields
404            .iter()
405            .filter(|f| f.name != CDC_OFFSET_COLUMN_NAME)
406            .map(|f| Self::quote_column(f.name.as_str()))
407            .join(",");
408
409        Ok(Self {
410            rw_schema,
411            field_names,
412            conn: tokio::sync::Mutex::new(conn),
413        })
414    }
415
416    pub fn get_normalized_table_name(table_name: &SchemaTableName) -> String {
417        // schema name is the database name in mysql
418        format!("`{}`.`{}`", table_name.schema_name, table_name.table_name)
419    }
420
421    pub fn get_cdc_offset_parser() -> CdcOffsetParseFunc {
422        Box::new(move |offset| {
423            Ok(CdcOffset::MySql(MySqlOffset::parse_debezium_offset(
424                offset,
425            )?))
426        })
427    }
428
429    #[try_stream(boxed, ok = OwnedRow, error = ConnectorError)]
430    async fn snapshot_read_inner(
431        &self,
432        table_name: SchemaTableName,
433        start_pk_row: Option<OwnedRow>,
434        primary_keys: Vec<String>,
435        limit: u32,
436    ) {
437        let order_key = primary_keys
438            .iter()
439            .map(|col| Self::quote_column(col))
440            .join(",");
441        let sql = if start_pk_row.is_none() {
442            format!(
443                "SELECT {} FROM {} ORDER BY {} LIMIT {limit}",
444                self.field_names,
445                Self::get_normalized_table_name(&table_name),
446                order_key,
447            )
448        } else {
449            let filter_expr = Self::filter_expression(&primary_keys);
450            format!(
451                "SELECT {} FROM {} WHERE {} ORDER BY {} LIMIT {limit}",
452                self.field_names,
453                Self::get_normalized_table_name(&table_name),
454                filter_expr,
455                order_key,
456            )
457        };
458
459        let mut conn = self.conn.lock().await;
460
461        // Set session timezone to UTC
462        conn.exec_drop("SET time_zone = \"+00:00\"", ()).await?;
463
464        if start_pk_row.is_none() {
465            let rs_stream = sql.stream::<mysql_async::Row, _>(&mut *conn).await?;
466            let row_stream = rs_stream.map(|row| {
467                // convert mysql row into OwnedRow
468                let mut row = row?;
469                Ok::<_, ConnectorError>(mysql_row_to_owned_row(&mut row, &self.rw_schema))
470            });
471
472            pin_mut!(row_stream);
473            #[for_await]
474            for row in row_stream {
475                let row = row?;
476                yield row;
477            }
478        } else {
479            let field_map = self
480                .rw_schema
481                .fields
482                .iter()
483                .map(|f| (f.name.as_str(), f.data_type.clone()))
484                .collect::<HashMap<_, _>>();
485
486            // fill in start primary key params
487            let params: Vec<_> = primary_keys
488                .iter()
489                .zip_eq_fast(start_pk_row.unwrap().into_iter())
490                .map(|(pk, datum)| {
491                    if let Some(value) = datum {
492                        let ty = field_map.get(pk.as_str()).unwrap();
493                        let val = match ty {
494                            DataType::Boolean => Value::from(value.into_bool()),
495                            DataType::Int16 => Value::from(value.into_int16()),
496                            DataType::Int32 => Value::from(value.into_int32()),
497                            DataType::Int64 => Value::from(value.into_int64()),
498                            DataType::Float32 => Value::from(value.into_float32().into_inner()),
499                            DataType::Float64 => Value::from(value.into_float64().into_inner()),
500                            DataType::Varchar => Value::from(String::from(value.into_utf8())),
501                            DataType::Date => Value::from(value.into_date().0),
502                            DataType::Time => Value::from(value.into_time().0),
503                            DataType::Timestamp => Value::from(value.into_timestamp().0),
504                            _ => bail!("unsupported primary key data type: {}", ty),
505                        };
506                        ConnectorResult::Ok((pk.to_lowercase(), val))
507                    } else {
508                        bail!("primary key {} cannot be null", pk);
509                    }
510                })
511                .try_collect::<_, _, ConnectorError>()?;
512
513            tracing::debug!("snapshot read params: {:?}", &params);
514            let rs_stream = sql
515                .with(Params::from(params))
516                .stream::<mysql_async::Row, _>(&mut *conn)
517                .await?;
518
519            let row_stream = rs_stream.map(|row| {
520                // convert mysql row into OwnedRow
521                let mut row = row?;
522                Ok::<_, ConnectorError>(mysql_row_to_owned_row(&mut row, &self.rw_schema))
523            });
524
525            pin_mut!(row_stream);
526            #[for_await]
527            for row in row_stream {
528                let row = row?;
529                yield row;
530            }
531        };
532    }
533
534    // mysql cannot leverage the given key to narrow down the range of scan,
535    // we need to rewrite the comparison conditions by our own.
536    // (a, b) > (x, y) => (`a` > x) OR ((`a` = x) AND (`b` > y))
537    fn filter_expression(columns: &[String]) -> String {
538        let mut conditions = vec![];
539        // push the first condition
540        conditions.push(format!(
541            "({} > :{})",
542            Self::quote_column(&columns[0]),
543            columns[0].to_lowercase()
544        ));
545        for i in 2..=columns.len() {
546            // '=' condition
547            let mut condition = String::new();
548            for (j, col) in columns.iter().enumerate().take(i - 1) {
549                if j == 0 {
550                    condition.push_str(&format!(
551                        "({} = :{})",
552                        Self::quote_column(col),
553                        col.to_lowercase()
554                    ));
555                } else {
556                    condition.push_str(&format!(
557                        " AND ({} = :{})",
558                        Self::quote_column(col),
559                        col.to_lowercase()
560                    ));
561                }
562            }
563            // '>' condition
564            condition.push_str(&format!(
565                " AND ({} > :{})",
566                Self::quote_column(&columns[i - 1]),
567                columns[i - 1].to_lowercase()
568            ));
569            conditions.push(format!("({})", condition));
570        }
571        if columns.len() > 1 {
572            conditions.join(" OR ")
573        } else {
574            conditions.join("")
575        }
576    }
577
578    fn quote_column(column: &str) -> String {
579        format!("`{}`", column)
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use std::collections::HashMap;
586
587    use futures::pin_mut;
588    use futures_async_stream::for_await;
589    use maplit::{convert_args, hashmap};
590    use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, Schema};
591    use risingwave_common::types::DataType;
592
593    use crate::source::cdc::external::mysql::MySqlExternalTable;
594    use crate::source::cdc::external::{
595        CdcOffset, ExternalTableConfig, ExternalTableReader, MySqlExternalTableReader, MySqlOffset,
596        SchemaTableName,
597    };
598
599    #[ignore]
600    #[tokio::test]
601    async fn test_mysql_schema() {
602        let config = ExternalTableConfig {
603            connector: "mysql-cdc".to_owned(),
604            host: "localhost".to_owned(),
605            port: "8306".to_owned(),
606            username: "root".to_owned(),
607            password: "123456".to_owned(),
608            database: "mydb".to_owned(),
609            schema: "".to_owned(),
610            table: "part".to_owned(),
611            ssl_mode: Default::default(),
612            ssl_root_cert: None,
613            encrypt: "false".to_owned(),
614        };
615
616        let table = MySqlExternalTable::connect(config).await.unwrap();
617        println!("columns: {:?}", &table.column_descs);
618        println!("primary keys: {:?}", &table.pk_names);
619    }
620
621    #[test]
622    fn test_mysql_filter_expr() {
623        let cols = vec!["id".to_owned()];
624        let expr = MySqlExternalTableReader::filter_expression(&cols);
625        assert_eq!(expr, "(`id` > :id)");
626
627        let cols = vec!["aa".to_owned(), "bb".to_owned(), "cc".to_owned()];
628        let expr = MySqlExternalTableReader::filter_expression(&cols);
629        assert_eq!(
630            expr,
631            "(`aa` > :aa) OR ((`aa` = :aa) AND (`bb` > :bb)) OR ((`aa` = :aa) AND (`bb` = :bb) AND (`cc` > :cc))"
632        );
633    }
634
635    #[test]
636    fn test_mysql_binlog_offset() {
637        let off0_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000001", "pos": 105622, "snapshot": true }, "isHeartbeat": false }"#;
638        let off1_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000007", "pos": 1062363217, "snapshot": true }, "isHeartbeat": false }"#;
639        let off2_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000007", "pos": 659687560, "snapshot": true }, "isHeartbeat": false }"#;
640        let off3_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000008", "pos": 7665875, "snapshot": true }, "isHeartbeat": false }"#;
641        let off4_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000008", "pos": 7665875, "snapshot": true }, "isHeartbeat": false }"#;
642
643        let off0 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off0_str).unwrap());
644        let off1 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off1_str).unwrap());
645        let off2 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off2_str).unwrap());
646        let off3 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off3_str).unwrap());
647        let off4 = CdcOffset::MySql(MySqlOffset::parse_debezium_offset(off4_str).unwrap());
648
649        assert!(off0 <= off1);
650        assert!(off1 > off2);
651        assert!(off2 < off3);
652        assert_eq!(off3, off4);
653    }
654
655    // manual test case
656    #[ignore]
657    #[tokio::test]
658    async fn test_mysql_table_reader() {
659        let columns = vec![
660            ColumnDesc::named("v1", ColumnId::new(1), DataType::Int32),
661            ColumnDesc::named("v2", ColumnId::new(2), DataType::Decimal),
662            ColumnDesc::named("v3", ColumnId::new(3), DataType::Varchar),
663            ColumnDesc::named("v4", ColumnId::new(4), DataType::Date),
664        ];
665        let rw_schema = Schema {
666            fields: columns.iter().map(Field::from).collect(),
667        };
668        let props: HashMap<String, String> = convert_args!(hashmap!(
669                "hostname" => "localhost",
670                "port" => "8306",
671                "username" => "root",
672                "password" => "123456",
673                "database.name" => "mytest",
674                "table.name" => "t1"));
675
676        let config =
677            serde_json::from_value::<ExternalTableConfig>(serde_json::to_value(props).unwrap())
678                .unwrap();
679        let reader = MySqlExternalTableReader::new(config, rw_schema)
680            .await
681            .unwrap();
682        let offset = reader.current_cdc_offset().await.unwrap();
683        println!("BinlogOffset: {:?}", offset);
684
685        let off0_str = r#"{ "sourcePartition": { "server": "test" }, "sourceOffset": { "ts_sec": 1670876905, "file": "binlog.000001", "pos": 105622, "snapshot": true }, "isHeartbeat": false }"#;
686        let parser = MySqlExternalTableReader::get_cdc_offset_parser();
687        println!("parsed offset: {:?}", parser(off0_str).unwrap());
688        let table_name = SchemaTableName {
689            schema_name: "mytest".to_owned(),
690            table_name: "t1".to_owned(),
691        };
692
693        let stream = reader.snapshot_read(table_name, None, vec!["v1".to_owned()], 1000);
694        pin_mut!(stream);
695        #[for_await]
696        for row in stream {
697            println!("OwnedRow: {:?}", row);
698        }
699    }
700}