Skip to main content

risingwave_connector/source/cdc/external/
sql_server.rs

1// Copyright 2024 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::cmp::Ordering;
16
17use anyhow::{Context, anyhow};
18use futures::stream::BoxStream;
19use futures::{StreamExt, TryStreamExt, pin_mut, stream};
20use futures_async_stream::try_stream;
21use itertools::Itertools;
22use risingwave_common::bail;
23use risingwave_common::catalog::{ColumnDesc, ColumnId, Field, Schema};
24use risingwave_common::row::OwnedRow;
25use risingwave_common::types::{DataType, ScalarImpl};
26use serde::{Deserialize, Serialize};
27use tiberius::{Config, Query, QueryItem};
28
29use crate::error::{ConnectorError, ConnectorResult};
30use crate::parser::{ScalarImplTiberiusWrapper, sql_server_row_to_owned_row_with_strict_pk};
31use crate::sink::sqlserver::SqlServerClient;
32use crate::source::CdcTableSnapshotSplit;
33use crate::source::cdc::external::{
34    CdcOffset, CdcOffsetParseFunc, CdcTableSnapshotSplitOption, DebeziumOffset,
35    ExternalTableConfig, ExternalTableReader, SchemaTableName,
36};
37
38// The maximum commit_lsn value in Sql Server
39const MAX_COMMIT_LSN: &str = "ffffffff:ffffffff:ffff";
40
41#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
42pub struct SqlServerOffset {
43    // https://learn.microsoft.com/en-us/answers/questions/1328359/how-to-accurately-sequence-change-data-capture-dat
44    pub change_lsn: String,
45    pub commit_lsn: String,
46}
47
48// only compare the lsn field
49impl PartialOrd for SqlServerOffset {
50    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
51        match self.change_lsn.partial_cmp(&other.change_lsn) {
52            Some(Ordering::Equal) => self.commit_lsn.partial_cmp(&other.commit_lsn),
53            other => other,
54        }
55    }
56}
57
58impl SqlServerOffset {
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            change_lsn: dbz_offset
65                .source_offset
66                .change_lsn
67                .context("invalid sql server change_lsn")?,
68            commit_lsn: dbz_offset
69                .source_offset
70                .commit_lsn
71                .context("invalid sql server commit_lsn")?,
72        })
73    }
74}
75
76pub struct SqlServerExternalTable {
77    column_descs: Vec<ColumnDesc>,
78    pk_names: Vec<String>,
79}
80
81impl SqlServerExternalTable {
82    pub async fn connect(config: ExternalTableConfig) -> ConnectorResult<Self> {
83        tracing::debug!("connect to sql server");
84
85        let mut client_config = Config::new();
86
87        client_config.host(&config.host);
88        client_config.database(&config.database);
89        client_config.port(config.port.parse::<u16>().unwrap());
90        client_config.authentication(tiberius::AuthMethod::sql_server(
91            &config.username,
92            &config.password,
93        ));
94        // TODO(kexiang): use trust_cert_ca, trust_cert is not secure
95        if config.encrypt == "true" {
96            client_config.encryption(tiberius::EncryptionLevel::Required);
97        }
98        client_config.trust_cert();
99
100        let mut client = SqlServerClient::new_with_config(client_config).await?;
101
102        let mut column_descs = vec![];
103        let mut pk_names = vec![];
104        {
105            let sql = Query::new(format!(
106                "SELECT
107                    COLUMN_NAME,
108                    DATA_TYPE
109                FROM
110                    INFORMATION_SCHEMA.COLUMNS
111                WHERE
112                    TABLE_SCHEMA = '{}'
113                    AND TABLE_NAME = '{}'",
114                config.schema.clone(),
115                config.table.clone(),
116            ));
117
118            let mut stream = sql.query(&mut client.inner_client).await?;
119            while let Some(item) = stream.try_next().await? {
120                match item {
121                    QueryItem::Metadata(_) => {}
122                    QueryItem::Row(row) => {
123                        let col_name: &str = row.try_get(0)?.unwrap();
124                        let col_type: &str = row.try_get(1)?.unwrap();
125                        column_descs.push(ColumnDesc::named(
126                            col_name,
127                            ColumnId::placeholder(),
128                            mssql_type_to_rw_type(col_type, col_name)?,
129                        ));
130                    }
131                }
132            }
133        }
134        {
135            let sql = Query::new(format!(
136                "SELECT kcu.COLUMN_NAME
137                FROM
138                    INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS tc
139                JOIN
140                    INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS kcu
141                    ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND
142                    tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND
143                    tc.TABLE_NAME = kcu.TABLE_NAME
144                WHERE
145                    tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND
146                    tc.TABLE_SCHEMA = '{}' AND tc.TABLE_NAME = '{}'",
147                config.schema, config.table,
148            ));
149
150            let mut stream = sql.query(&mut client.inner_client).await?;
151            while let Some(item) = stream.try_next().await? {
152                match item {
153                    QueryItem::Metadata(_) => {}
154                    QueryItem::Row(row) => {
155                        let pk_name: &str = row.try_get(0)?.unwrap();
156                        pk_names.push(pk_name.to_owned());
157                    }
158                }
159            }
160        }
161
162        // The table does not exist
163        if column_descs.is_empty() {
164            bail!(
165                "Sql Server table '{}'.'{}' not found in '{}'",
166                config.schema,
167                config.table,
168                config.database
169            );
170        }
171
172        Ok(Self {
173            column_descs,
174            pk_names,
175        })
176    }
177
178    pub fn column_descs(&self) -> &Vec<ColumnDesc> {
179        &self.column_descs
180    }
181
182    pub fn pk_names(&self) -> &Vec<String> {
183        &self.pk_names
184    }
185}
186
187fn mssql_type_to_rw_type(col_type: &str, col_name: &str) -> ConnectorResult<DataType> {
188    let dtype = match col_type.to_lowercase().as_str() {
189        "bit" => DataType::Boolean,
190        "binary" | "varbinary" => DataType::Bytea,
191        "tinyint" | "smallint" => DataType::Int16,
192        "integer" | "int" => DataType::Int32,
193        "bigint" => DataType::Int64,
194        "real" => DataType::Float32,
195        "float" => DataType::Float64,
196        "decimal" | "numeric" => DataType::Decimal,
197        "date" => DataType::Date,
198        "time" => DataType::Time,
199        "datetime" | "datetime2" | "smalldatetime" => DataType::Timestamp,
200        "datetimeoffset" => DataType::Timestamptz,
201        "char" | "nchar" | "varchar" | "nvarchar" | "text" | "ntext" | "xml"
202        | "uniqueidentifier" => DataType::Varchar,
203        "money" => DataType::Decimal,
204        mssql_type => {
205            return Err(anyhow!(
206                "Unsupported Sql Server data type: {:?}, column name: {}",
207                mssql_type,
208                col_name
209            )
210            .into());
211        }
212    };
213    Ok(dtype)
214}
215
216#[derive(Debug)]
217pub struct SqlServerExternalTableReader {
218    rw_schema: Schema,
219    pk_indices: Vec<usize>,
220    field_names: String,
221    client: tokio::sync::Mutex<SqlServerClient>,
222}
223
224impl ExternalTableReader for SqlServerExternalTableReader {
225    async fn current_cdc_offset(&self) -> ConnectorResult<CdcOffset> {
226        let mut client = self.client.lock().await;
227        // start a transaction to read max start_lsn.
228        let row = client
229            .inner_client
230            .simple_query(String::from("SELECT sys.fn_cdc_get_max_lsn()"))
231            .await?
232            .into_row()
233            .await?
234            .expect("No result returned by `SELECT sys.fn_cdc_get_max_lsn()`");
235        // An example of change_lsn or commit_lsn: "00000027:00000ac0:0002" from debezium
236        // sys.fn_cdc_get_max_lsn() returns a 10 bytes array, we convert it to a hex string here.
237        let max_lsn = match row.try_get::<&[u8], usize>(0)? {
238            Some(bytes) => {
239                let mut hex_string = String::with_capacity(bytes.len() * 2 + 2);
240                assert_eq!(
241                    bytes.len(),
242                    10,
243                    "sys.fn_cdc_get_max_lsn() should return a 10 bytes array."
244                );
245                for byte in &bytes[0..4] {
246                    hex_string.push_str(&format!("{:02x}", byte));
247                }
248                hex_string.push(':');
249                for byte in &bytes[4..8] {
250                    hex_string.push_str(&format!("{:02x}", byte));
251                }
252                hex_string.push(':');
253                for byte in &bytes[8..10] {
254                    hex_string.push_str(&format!("{:02x}", byte));
255                }
256                hex_string
257            }
258            None => bail!(
259                "None is returned by `SELECT sys.fn_cdc_get_max_lsn()`, please ensure Sql Server Agent is running."
260            ),
261        };
262
263        tracing::debug!("current max_lsn: {}", max_lsn);
264
265        Ok(CdcOffset::SqlServer(SqlServerOffset {
266            change_lsn: max_lsn,
267            commit_lsn: MAX_COMMIT_LSN.into(),
268        }))
269    }
270
271    fn snapshot_read(
272        &self,
273        table_name: SchemaTableName,
274        start_pk: Option<OwnedRow>,
275        primary_keys: Vec<String>,
276        limit: u32,
277    ) -> BoxStream<'_, ConnectorResult<OwnedRow>> {
278        self.snapshot_read_inner(table_name, start_pk, primary_keys, limit)
279    }
280
281    fn get_parallel_cdc_splits(
282        &self,
283        _options: CdcTableSnapshotSplitOption,
284    ) -> BoxStream<'_, ConnectorResult<CdcTableSnapshotSplit>> {
285        // TODO(zw): feat: impl
286        stream::empty::<ConnectorResult<CdcTableSnapshotSplit>>().boxed()
287    }
288
289    fn split_snapshot_read(
290        &self,
291        _table_name: SchemaTableName,
292        _left: OwnedRow,
293        _right: OwnedRow,
294        _split_columns: Vec<Field>,
295    ) -> BoxStream<'_, ConnectorResult<OwnedRow>> {
296        todo!("implement SqlServer CDC parallelized backfill")
297    }
298}
299
300impl SqlServerExternalTableReader {
301    pub async fn new(
302        config: ExternalTableConfig,
303        rw_schema: Schema,
304        pk_indices: Vec<usize>,
305    ) -> ConnectorResult<Self> {
306        tracing::info!(
307            ?rw_schema,
308            ?pk_indices,
309            "create sql server external table reader"
310        );
311        let mut client_config = Config::new();
312
313        client_config.host(&config.host);
314        client_config.database(&config.database);
315        client_config.port(config.port.parse::<u16>().unwrap());
316        client_config.authentication(tiberius::AuthMethod::sql_server(
317            &config.username,
318            &config.password,
319        ));
320        // TODO(kexiang): use trust_cert_ca, trust_cert is not secure
321        if config.encrypt == "true" {
322            client_config.encryption(tiberius::EncryptionLevel::Required);
323        }
324        client_config.trust_cert();
325
326        let client = SqlServerClient::new_with_config(client_config).await?;
327
328        let field_names = rw_schema
329            .fields
330            .iter()
331            .map(|f| Self::quote_column(&f.name))
332            .join(",");
333
334        Ok(Self {
335            rw_schema,
336            pk_indices,
337            field_names,
338            client: tokio::sync::Mutex::new(client),
339        })
340    }
341
342    pub fn get_cdc_offset_parser() -> CdcOffsetParseFunc {
343        Box::new(move |offset| {
344            Ok(CdcOffset::SqlServer(
345                SqlServerOffset::parse_debezium_offset(offset)?,
346            ))
347        })
348    }
349
350    #[try_stream(boxed, ok = OwnedRow, error = ConnectorError)]
351    async fn snapshot_read_inner(
352        &self,
353        table_name: SchemaTableName,
354        start_pk_row: Option<OwnedRow>,
355        primary_keys: Vec<String>,
356        limit: u32,
357    ) {
358        let order_key = primary_keys
359            .iter()
360            .map(|col| Self::quote_column(col))
361            .join(",");
362        let mut sql = Query::new(if start_pk_row.is_none() {
363            format!(
364                "SELECT {} FROM {} ORDER BY {} OFFSET 0 ROWS FETCH NEXT {limit} ROWS ONLY",
365                self.field_names,
366                Self::get_normalized_table_name(&table_name),
367                order_key,
368            )
369        } else {
370            let filter_expr = Self::filter_expression(&primary_keys);
371            format!(
372                "SELECT {} FROM {} WHERE {} ORDER BY {} OFFSET 0 ROWS FETCH NEXT {limit} ROWS ONLY",
373                self.field_names,
374                Self::get_normalized_table_name(&table_name),
375                filter_expr,
376                order_key,
377            )
378        });
379
380        let mut client = self.client.lock().await;
381
382        // FIXME(kexiang): Set session timezone to UTC
383        if let Some(pk_row) = start_pk_row {
384            let params: Vec<Option<ScalarImpl>> = pk_row.into_iter().collect();
385            for (index, param) in params.into_iter().enumerate() {
386                let param = param.with_context(|| {
387                    format!(
388                        "SQL Server snapshot primary-key position at index {index} cannot be NULL"
389                    )
390                })?;
391                sql.bind(ScalarImplTiberiusWrapper::from(param));
392            }
393        }
394
395        let stream = sql.query(&mut client.inner_client).await?.into_row_stream();
396
397        let row_stream = stream.map(|res| {
398            // convert sql server row into OwnedRow
399            let mut row = res?;
400            sql_server_row_to_owned_row_with_strict_pk(&mut row, &self.rw_schema, &self.pk_indices)
401                .map_err(ConnectorError::from)
402        });
403
404        pin_mut!(row_stream);
405
406        #[for_await]
407        for row in row_stream {
408            let row = row?;
409            yield row;
410        }
411    }
412
413    pub fn get_normalized_table_name(table_name: &SchemaTableName) -> String {
414        format!(
415            "\"{}\".\"{}\"",
416            table_name.schema_name, table_name.table_name
417        )
418    }
419
420    // sql server cannot leverage the given key to narrow down the range of scan,
421    // we need to rewrite the comparison conditions by our own.
422    // (a, b) > (x, y) => ("a" > @P1) OR (("a" = @P1) AND ("b" > @P2))
423    fn filter_expression(columns: &[String]) -> String {
424        let mut conditions = vec![];
425        // push the first condition
426        conditions.push(format!("({} > @P{})", Self::quote_column(&columns[0]), 1));
427        for i in 2..=columns.len() {
428            // '=' condition
429            let mut condition = String::new();
430            for (j, col) in columns.iter().enumerate().take(i - 1) {
431                if j == 0 {
432                    condition.push_str(&format!("({} = @P{})", Self::quote_column(col), j + 1));
433                } else {
434                    condition.push_str(&format!(
435                        " AND ({} = @P{})",
436                        Self::quote_column(col),
437                        j + 1
438                    ));
439                }
440            }
441            // '>' condition
442            condition.push_str(&format!(
443                " AND ({} > @P{})",
444                Self::quote_column(&columns[i - 1]),
445                i
446            ));
447            conditions.push(format!("({})", condition));
448        }
449        if columns.len() > 1 {
450            conditions.join(" OR ")
451        } else {
452            conditions.join("")
453        }
454    }
455
456    fn quote_column(column: &str) -> String {
457        format!("\"{}\"", column)
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use crate::source::cdc::external::SqlServerExternalTableReader;
464
465    #[test]
466    fn test_sql_server_filter_expr() {
467        let cols = vec!["id".to_owned()];
468        let expr = SqlServerExternalTableReader::filter_expression(&cols);
469        assert_eq!(expr, "(\"id\" > @P1)");
470
471        let cols = vec!["aa".to_owned(), "bb".to_owned(), "cc".to_owned()];
472        let expr = SqlServerExternalTableReader::filter_expression(&cols);
473        assert_eq!(
474            expr,
475            "(\"aa\" > @P1) OR ((\"aa\" = @P1) AND (\"bb\" > @P2)) OR ((\"aa\" = @P1) AND (\"bb\" = @P2) AND (\"cc\" > @P3))"
476        );
477    }
478}