Skip to main content

risingwave_batch_executors/executor/
mssql_query.rs

1// Copyright 2026 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 anyhow::Context;
16use futures_async_stream::try_stream;
17use futures_util::stream::StreamExt;
18use risingwave_common::array::DataChunk;
19use risingwave_common::catalog::{Field, Schema};
20use risingwave_common::util::chunk_coalesce::DataChunkBuilder;
21use risingwave_connector::connector_common::sql_server::{
22    MssqlConnectionConfig, create_mssql_client,
23};
24use risingwave_connector::parser::SqlServerRowDecoder;
25use risingwave_pb::batch_plan::plan_node::NodeBody;
26
27use crate::error::BatchError;
28use crate::executor::{BoxedExecutor, BoxedExecutorBuilder, Executor, ExecutorBuilder};
29
30/// `MssqlQuery` executor. Runs a query against a SQL Server database via `tiberius`.
31pub struct MssqlQueryExecutor {
32    schema: Schema,
33    config: MssqlConnectionConfig,
34    query: String,
35    identity: String,
36    chunk_size: usize,
37}
38
39impl Executor for MssqlQueryExecutor {
40    /// Return the result schema, which was discovered at bind time via
41    /// `describe_mssql_query` and stored at construction. The schema has one
42    /// field per visible column of the user query (synthetically named
43    /// `column_N` for unnamed columns such as `SELECT COUNT(*)`).
44    fn schema(&self) -> &risingwave_common::catalog::Schema {
45        &self.schema
46    }
47
48    /// Return the identity string copied from the plan node, used in
49    /// tracing and error messages.
50    fn identity(&self) -> &str {
51        &self.identity
52    }
53
54    /// Consume the executor and return a stream of `DataChunk`s. Delegates
55    /// to the `#[try_stream]`-decorated `do_execute` async fn.
56    fn execute(self: Box<Self>) -> super::BoxedDataChunkStream {
57        self.do_execute().boxed()
58    }
59}
60
61impl MssqlQueryExecutor {
62    /// Build a new [`MssqlQueryExecutor`] from the pre-discovered schema,
63    /// connection config, the user query, the plan node identity, and the
64    /// batch chunk size.
65    /// The schema is computed at bind time by `describe_mssql_query`;
66    /// this constructor only stores it.
67    pub fn new(
68        schema: Schema,
69        config: MssqlConnectionConfig,
70        query: String,
71        identity: String,
72        chunk_size: usize,
73    ) -> Self {
74        Self {
75            schema,
76            config,
77            query,
78            identity,
79            chunk_size,
80        }
81    }
82
83    /// Stream query results. Connects to SQL Server via `tiberius`, runs
84    /// `self.query` verbatim, and yields each [`DataChunk`] decoded through
85    /// [`SqlServerRowDecoder`]. Errors from connection setup, query
86    /// execution, or row decoding are propagated as [`BatchError`].
87    #[try_stream(ok = DataChunk, error = BatchError)]
88    async fn do_execute(self: Box<Self>) {
89        tracing::debug!("mssql_query_executor: started");
90
91        let mut client = create_mssql_client(&self.config)
92            .await
93            .context("mssql_query: failed to connect to sql server")?;
94
95        tracing::debug!(
96            query = %self.query,
97            "mssql_query_executor: running query"
98        );
99
100        // Run the user-provided query verbatim (no parameter binding).
101        // `Client::query` returns a `QueryStream`; `into_row_stream` yields one
102        // `tiberius::Row` per item. The row type is inferred — we never name
103        // `tiberius::Row` directly so `tiberius` doesn't need to be a direct
104        // dep of this crate.
105        let mut query_stream = client
106            .query(self.query.as_str(), &[])
107            .await
108            .context("mssql_query received error from remote server")?;
109
110        // TDS result metadata precedes the rows. Classify MONEY columns once
111        // from that metadata rather than inferring them from RisingWave's
112        // broader Decimal type or inspecting every row again.
113        let decoder = SqlServerRowDecoder::new(
114            query_stream
115                .columns()
116                .await
117                .context("mssql_query: failed to read result metadata")?
118                .context("mssql_query: query returned no result metadata")?,
119            &self.schema,
120        )
121        .context("mssql_query: result metadata does not match the planned schema")?;
122        let row_stream = query_stream.into_row_stream();
123
124        let mut builder = DataChunkBuilder::new(self.schema.data_types(), self.chunk_size);
125        tracing::debug!("mssql_query_executor: query executed, start deserializing rows");
126
127        futures::pin_mut!(row_stream);
128
129        // Deserialize rows. Decoding is delegated to the canonical
130        // [`SqlServerRowDecoder`] helper in the connector crate, which
131        // routes through [`ScalarImplTiberiusWrapper`] — the RisingWave-side
132        // `tiberius::FromSql` impl. It handles: `Decimal` ← `Numeric`,
133        // `Timestamptz` ← `DateTimeOffset`, `Varchar` ← `UniqueIdentifier`
134        // (uppercased) / `Xml`, etc.
135        //
136        // Cells whose target [`DataType`] has no direct tiberius mapping (e.g.
137        // `Jsonb`, `Interval`) are returned as NULL with a warning. Cells
138        // whose underlying type mismatches the requested RisingWave type
139        // (NULL on non-nullable, wire type that can't be coerced) also fall
140        // through to NULL — failing the whole query for one bad cell is too
141        // aggressive for an ad-hoc table-valued function.
142        #[for_await]
143        for row_result in row_stream {
144            let row = row_result.context("mssql_query: row decode error")?;
145            // Delegate per-cell decoding to the canonical helper in the connector crate.
146            // It already handles types without a direct tiberius mapping (Jsonb, Interval, …)
147            // by emitting NULL with a logged warning, and it owns the
148            // `ScalarImplTiberiusWrapper` glue which is private to the connector crate.
149            let mut row_mut = row;
150            let owned_row = decoder
151                .decode(&mut row_mut)
152                .context("mssql_query: result row does not match its metadata")?;
153            if let Some(chunk) = builder.append_one_row(owned_row) {
154                yield chunk;
155            }
156        }
157        if let Some(chunk) = builder.consume_all() {
158            yield chunk;
159        }
160    }
161}
162
163/// Builder for [`MssqlQueryExecutor`]. Unpacks a [`NodeBody::MssqlQuery`]
164/// batch plan node into a ready-to-execute [`MssqlQueryExecutor`].
165pub struct MssqlQueryExecutorBuilder {}
166
167impl BoxedExecutorBuilder for MssqlQueryExecutorBuilder {
168    /// Decode a `NodeBody::MssqlQuery` batch plan node, parse the SQL Server
169    /// port as `u16`, and assemble the [`MssqlConnectionConfig`]. TLS flags
170    /// are already typed booleans in the protobuf plan.
171    async fn new_boxed_executor(
172        source: &ExecutorBuilder<'_>,
173        inputs: Vec<BoxedExecutor>,
174    ) -> crate::error::Result<BoxedExecutor> {
175        ensure!(
176            inputs.is_empty(),
177            "MssqlQueryExecutor should have no child executor"
178        );
179        let mssql_query_node = try_match_expand!(
180            source.plan_node().get_node_body().unwrap(),
181            NodeBody::MssqlQuery
182        )?;
183
184        let port = mssql_query_node
185            .port
186            .parse::<u16>()
187            .with_context(|| format!("invalid sql server port `{}`", mssql_query_node.port))?;
188
189        Ok(Box::new(MssqlQueryExecutor::new(
190            Schema::from_iter(mssql_query_node.columns.iter().map(Field::from)),
191            MssqlConnectionConfig {
192                host: mssql_query_node.hostname.clone(),
193                port,
194                user: mssql_query_node.username.clone(),
195                password: mssql_query_node.password.clone(),
196                database: mssql_query_node.database.clone(),
197                encrypt: mssql_query_node.encrypt,
198                trust_cert: mssql_query_node.trust_cert,
199            },
200            mssql_query_node.query.clone(),
201            source.plan_node().get_identity().clone(),
202            source.context().get_config().developer.chunk_size,
203        )))
204    }
205}