Skip to main content

faucet_source_mysql/
stream.rs

1//! MySQL source implementation.
2
3use 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
12/// A source that executes a SQL query against MySQL and returns rows as JSON.
13pub struct MysqlSource {
14    config: MysqlSourceConfig,
15    pool: MySqlPool,
16}
17
18impl MysqlSource {
19    /// Create a new MySQL source. Establishes a connection pool.
20    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
33/// Convert a MySQL row column value to a `serde_json::Value`.
34///
35/// Attempts common types in order of likelihood. Falls back to `Value::Null`
36/// for unsupported or null columns.
37fn mysql_value_to_json(row: &sqlx::mysql::MySqlRow, col_name: &str) -> Value {
38    // Try JSON first
39    if let Ok(v) = row.try_get::<Value, _>(col_name) {
40        return v;
41    }
42
43    // Try common scalar types
44    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    // UNSIGNED integer columns (#264). sqlx-mysql treats UNSIGNED as a
57    // distinct type from the signed decoders above, so without these arms
58    // UNSIGNED columns fall through to the `bool` probe (TINYINT UNSIGNED ->
59    // bool) or to the `Null` fall-through (larger UNSIGNED -> null), silently
60    // corrupting every unsigned column including UNSIGNED primary keys.
61    //
62    // These are placed *after* the signed probes so a signed column always
63    // matches a signed arm first, and *before* `bool`/`f64`/`f32` so a
64    // `TINYINT UNSIGNED` decodes as a number rather than a bool (MySQL's
65    // boolean is `TINYINT(1)`). `u64` fits `serde_json::Number` exactly, so
66    // BIGINT UNSIGNED values above `i64::MAX` round-trip losslessly.
67    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    // Richer types that would otherwise silently decode to Null (#78/#43).
94    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    // DECIMAL → string, preserving exact precision.
109    if let Ok(v) = row.try_get::<sqlx::types::BigDecimal, _>(col_name) {
110        return Value::String(v.to_string());
111    }
112    // BLOB / BINARY → base64.
113    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
121/// Build the effective SQL query and ordered context-bind values for a given
122/// parent context. Returns the literal query when there is no context.
123fn 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/// How a numeric bind value should be bound onto a sqlx query.
137///
138/// Classifying *before* binding keeps the integer/float decision in one pure,
139/// unit-testable place and — critically — binds any integer in
140/// `[i64::MIN, i64::MAX]` as an exact `i64` rather than an `f64`. Binding an
141/// integer above `2^53` as `f64` silently rounds it (audit F38), so a large
142/// 64-bit id threaded into `WHERE id = ?` would compare against the *wrong*
143/// value and return wrong rows.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum NumberBind {
146    /// Exact `i64` — covers every integer in `[i64::MIN, i64::MAX]`.
147    I64,
148    /// Value above `i64::MAX`; bind as `u64` (MySQL has native UNSIGNED).
149    U64,
150    /// Genuine floating-point value — bind as `f64`.
151    F64,
152}
153
154/// Classify a JSON number into the bind category to use.
155///
156/// `is_i64()` losslessly covers `[i64::MIN, i64::MAX]` (including the
157/// `(2^53, i64::MAX]` range that `f64` would round); `is_u64()` covers values
158/// above `i64::MAX`; everything else is a real float.
159fn 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
169/// Apply context-derived bind values onto a sqlx query.
170fn 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                // `unwrap()` is sound: the classifier proves the predicate.
179                NumberBind::I64 => query.bind(n.as_i64().unwrap()),
180                // MySQL has a native UNSIGNED BIGINT type, so bind the `u64`
181                // directly — values above `i64::MAX` round-trip losslessly.
182                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
193/// Convert a single `MySqlRow` into a JSON object whose keys are the row's
194/// column names.
195fn 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    /// Stream rows from the underlying sqlx cursor without buffering the full
225    /// result set. Each emitted [`StreamPage`] holds up to
226    /// [`MysqlSourceConfig::batch_size`] rows.
227    ///
228    /// The trait-level `batch_size` argument is ignored in favour of the
229    /// config field — the config is the user-facing knob the README
230    /// documents, and routing the pipeline-supplied hint through it would
231    /// silently override an explicit config value.
232    ///
233    /// `batch_size = 0` drains the entire cursor into a single page. The
234    /// mysql query source has no incremental-replication mode today, so
235    /// every emitted page carries `bookmark: None`.
236    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    // dataset_uri is a pure-config method; the source requires a live DB to
310    // construct so we verify the credential-stripping logic directly.
311    #[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    // ── F38: numeric bind classification (precision-safe) ───────────────────
319
320    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        // 2^53 + 1 must NOT be bound as f64 (which would round it). It is a
343        // valid i64, so it must classify as I64.
344        let v = 9_007_199_254_740_993i64; // 2^53 + 1
345        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}