1use crate::config::MysqlSourceConfig;
4use async_trait::async_trait;
5use faucet_core::{FaucetError, Stream, StreamPage};
6use futures::TryStreamExt;
7use serde_json::Value;
8use sqlx::mysql::MySqlPoolOptions;
9use sqlx::{Column, MySqlPool, Row};
10use std::pin::Pin;
11
12pub struct MysqlSource {
14 config: MysqlSourceConfig,
15 pool: MySqlPool,
16}
17
18impl MysqlSource {
19 pub async fn new(config: MysqlSourceConfig) -> Result<Self, FaucetError> {
21 faucet_core::validate_batch_size(config.batch_size)?;
22
23 let pool = MySqlPoolOptions::new()
24 .max_connections(config.max_connections)
25 .connect(&config.connection_url)
26 .await
27 .map_err(|e| FaucetError::Config(format!("MySQL connection failed: {e}")))?;
28
29 Ok(Self { config, pool })
30 }
31}
32
33fn mysql_value_to_json(row: &sqlx::mysql::MySqlRow, col_name: &str) -> Value {
38 if let Ok(v) = row.try_get::<Value, _>(col_name) {
40 return v;
41 }
42
43 if let Ok(v) = row.try_get::<String, _>(col_name) {
45 return Value::String(v);
46 }
47 if let Ok(v) = row.try_get::<i64, _>(col_name) {
48 return Value::Number(v.into());
49 }
50 if let Ok(v) = row.try_get::<i32, _>(col_name) {
51 return Value::Number(v.into());
52 }
53 if let Ok(v) = row.try_get::<i16, _>(col_name) {
54 return Value::Number(v.into());
55 }
56 if let Ok(v) = row.try_get::<u64, _>(col_name) {
68 return Value::Number(v.into());
69 }
70 if let Ok(v) = row.try_get::<u32, _>(col_name) {
71 return Value::Number(v.into());
72 }
73 if let Ok(v) = row.try_get::<u16, _>(col_name) {
74 return Value::Number(v.into());
75 }
76 if let Ok(v) = row.try_get::<u8, _>(col_name) {
77 return Value::Number(v.into());
78 }
79 if let Ok(v) = row.try_get::<f64, _>(col_name) {
80 return serde_json::Number::from_f64(v)
81 .map(Value::Number)
82 .unwrap_or(Value::Null);
83 }
84 if let Ok(v) = row.try_get::<f32, _>(col_name) {
85 return serde_json::Number::from_f64(v as f64)
86 .map(Value::Number)
87 .unwrap_or(Value::Null);
88 }
89 if let Ok(v) = row.try_get::<bool, _>(col_name) {
90 return Value::Bool(v);
91 }
92
93 if let Ok(v) =
95 row.try_get::<sqlx::types::chrono::DateTime<sqlx::types::chrono::Utc>, _>(col_name)
96 {
97 return Value::String(v.to_rfc3339());
98 }
99 if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDateTime, _>(col_name) {
100 return Value::String(v.to_string());
101 }
102 if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDate, _>(col_name) {
103 return Value::String(v.to_string());
104 }
105 if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveTime, _>(col_name) {
106 return Value::String(v.to_string());
107 }
108 if let Ok(v) = row.try_get::<sqlx::types::BigDecimal, _>(col_name) {
110 return Value::String(v.to_string());
111 }
112 if let Ok(v) = row.try_get::<Vec<u8>, _>(col_name) {
114 use base64::Engine as _;
115 return Value::String(base64::engine::general_purpose::STANDARD.encode(v));
116 }
117
118 Value::Null
119}
120
121fn resolve_query(
124 config: &MysqlSourceConfig,
125 context: &std::collections::HashMap<String, Value>,
126) -> (String, Vec<Value>) {
127 if context.is_empty() {
128 (config.query.clone(), Vec::new())
129 } else {
130 faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |_| {
131 "?".to_string()
132 })
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum NumberBind {
146 I64,
148 U64,
150 F64,
152}
153
154fn classify_number(n: &serde_json::Number) -> NumberBind {
160 if n.is_i64() {
161 NumberBind::I64
162 } else if n.is_u64() {
163 NumberBind::U64
164 } else {
165 NumberBind::F64
166 }
167}
168
169fn bind_params<'q>(
171 mut query: sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>,
172 bind_values: &'q [Value],
173) -> sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments> {
174 for value in bind_values {
175 query = match value {
176 Value::String(s) => query.bind(s.clone()),
177 Value::Number(n) => match classify_number(n) {
178 NumberBind::I64 => query.bind(n.as_i64().unwrap()),
180 NumberBind::U64 => query.bind(n.as_u64().unwrap()),
183 NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
184 },
185 Value::Bool(b) => query.bind(*b),
186 Value::Null => query.bind(None::<String>),
187 _ => query.bind(value.to_string()),
188 };
189 }
190 query
191}
192
193fn row_to_json(row: &sqlx::mysql::MySqlRow) -> Value {
196 let mut map = serde_json::Map::new();
197 for col in row.columns() {
198 let name = col.name().to_string();
199 let value = mysql_value_to_json(row, &name);
200 map.insert(name, value);
201 }
202 Value::Object(map)
203}
204
205#[async_trait]
206impl faucet_core::Source for MysqlSource {
207 async fn fetch_with_context(
208 &self,
209 context: &std::collections::HashMap<String, serde_json::Value>,
210 ) -> Result<Vec<Value>, FaucetError> {
211 let (query_str, bind_values) = resolve_query(&self.config, context);
212 let query = bind_params(sqlx::query(&query_str), &bind_values);
213
214 let rows = query
215 .fetch_all(&self.pool)
216 .await
217 .map_err(|e| FaucetError::Config(format!("MySQL query failed: {e}")))?;
218
219 let records: Vec<Value> = rows.iter().map(row_to_json).collect();
220 tracing::info!(rows = records.len(), query = %self.config.query, "MySQL source fetch complete");
221 Ok(records)
222 }
223
224 fn stream_pages<'a>(
237 &'a self,
238 context: &'a std::collections::HashMap<String, Value>,
239 _batch_size: usize,
240 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
241 let batch_size = self.config.batch_size;
242
243 Box::pin(async_stream::try_stream! {
244 let (query_str, bind_values) = resolve_query(&self.config, context);
245 let query = bind_params(sqlx::query(&query_str), &bind_values);
246
247 let mut rows = query.fetch(&self.pool);
248 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
249 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
250 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
251 let mut total = 0usize;
252
253 while let Some(row) = rows
254 .try_next()
255 .await
256 .map_err(|e| FaucetError::Config(format!("MySQL query failed: {e}")))?
257 {
258 buffer.push(row_to_json(&row));
259 if buffer.len() >= chunk {
260 let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
261 total += page.len();
262 yield StreamPage { records: page, bookmark: None };
263 }
264 }
265 if !buffer.is_empty() {
266 total += buffer.len();
267 yield StreamPage { records: buffer, bookmark: None };
268 }
269
270 tracing::info!(
271 rows = total,
272 batch_size,
273 query = %self.config.query,
274 "MySQL source stream complete",
275 );
276 })
277 }
278
279 fn config_schema(&self) -> serde_json::Value {
280 serde_json::to_value(faucet_core::schema_for!(MysqlSourceConfig))
281 .expect("schema serialization")
282 }
283
284 fn dataset_uri(&self) -> String {
285 format!(
286 "{}?query={}",
287 faucet_core::redact_uri_credentials(&self.config.connection_url),
288 self.config.query
289 )
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[tokio::test]
298 async fn new_rejects_out_of_range_batch_size() {
299 let mut config = MysqlSourceConfig::new("mysql://localhost/test", "SELECT 1");
300 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
301 match MysqlSource::new(config).await {
302 Err(faucet_core::FaucetError::Config(m)) => {
303 assert!(m.contains("batch_size"), "got: {m}")
304 }
305 _ => panic!("expected a batch_size Config error"),
306 }
307 }
308
309 #[test]
312 fn dataset_uri_strips_credentials() {
313 let redacted = faucet_core::redact_uri_credentials("mysql://u:p@h:3306/db");
314 let uri = format!("{}?query={}", redacted, "SELECT 1");
315 assert_eq!(uri, "mysql://h:3306/db?query=SELECT 1");
316 }
317
318 fn num(v: serde_json::Value) -> serde_json::Number {
321 match v {
322 serde_json::Value::Number(n) => n,
323 _ => panic!("not a number"),
324 }
325 }
326
327 #[test]
328 fn classify_small_int_is_i64() {
329 assert_eq!(
330 classify_number(&num(serde_json::json!(42))),
331 NumberBind::I64
332 );
333 assert_eq!(
334 classify_number(&num(serde_json::json!(-7))),
335 NumberBind::I64
336 );
337 assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
338 }
339
340 #[test]
341 fn classify_above_2_pow_53_stays_i64_not_f64() {
342 let v = 9_007_199_254_740_993i64; assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
346 }
347
348 #[test]
349 fn classify_i64_boundaries_are_i64() {
350 assert_eq!(
351 classify_number(&num(serde_json::json!(i64::MAX))),
352 NumberBind::I64
353 );
354 assert_eq!(
355 classify_number(&num(serde_json::json!(i64::MIN))),
356 NumberBind::I64
357 );
358 }
359
360 #[test]
361 fn classify_above_i64_max_is_u64() {
362 let v: u64 = i64::MAX as u64 + 1;
363 assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
364 assert_eq!(
365 classify_number(&num(serde_json::json!(u64::MAX))),
366 NumberBind::U64
367 );
368 }
369
370 #[test]
371 fn classify_float_is_f64() {
372 assert_eq!(
373 classify_number(&num(serde_json::json!(3.5))),
374 NumberBind::F64
375 );
376 }
377}