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}