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