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::shard::{
6    PkShardBounds, ShardSpec, parse_pk_shard, pk_bounds_query, pk_shards_from_bounds,
7};
8use faucet_core::{FaucetError, Stream, StreamPage};
9use futures::TryStreamExt;
10use serde_json::Value;
11use sqlx::mysql::MySqlPoolOptions;
12use sqlx::{Column, MySqlPool, Row};
13use std::pin::Pin;
14use std::sync::Mutex;
15
16/// A source that executes a SQL query against MySQL and returns rows as JSON.
17pub struct MysqlSource {
18    config: MysqlSourceConfig,
19    pool: MySqlPool,
20    /// Shard applied by the cluster coordinator (Mode B), if any. `None` (or the
21    /// whole-dataset shard) means the full query is streamed. Stored behind a
22    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
23    applied_shard: Mutex<Option<PkShardBounds>>,
24}
25
26/// Quote a MySQL identifier with backticks (MySQL's default identifier
27/// quoting — double quotes require the non-default `ANSI_QUOTES` sql_mode).
28/// Embedded backticks are doubled, preventing identifier injection.
29fn quote_ident_mysql(name: &str) -> String {
30    format!("`{}`", name.replace('`', "``"))
31}
32
33impl MysqlSource {
34    /// Create a new MySQL source. Establishes a connection pool.
35    pub async fn new(config: MysqlSourceConfig) -> Result<Self, FaucetError> {
36        faucet_core::validate_batch_size(config.batch_size)?;
37
38        let pool = MySqlPoolOptions::new()
39            .max_connections(config.max_connections)
40            .connect(&config.connection_url)
41            .await
42            .map_err(|e| FaucetError::Config(format!("MySQL connection failed: {e}")))?;
43
44        Ok(Self {
45            config,
46            pool,
47            applied_shard: Mutex::new(None),
48        })
49    }
50
51    /// Apply the currently-set shard (if any) to a resolved query string.
52    fn shard_wrap(&self, query: String) -> String {
53        match &*self.applied_shard.lock().expect("shard mutex poisoned") {
54            Some(bounds) => bounds.wrap(&query, quote_ident_mysql),
55            None => query,
56        }
57    }
58}
59
60/// Convert a MySQL row column value to a `serde_json::Value`.
61///
62/// Attempts common types in order of likelihood. Falls back to `Value::Null`
63/// for unsupported or null columns.
64fn mysql_value_to_json(row: &sqlx::mysql::MySqlRow, col_name: &str) -> Value {
65    // Try JSON first
66    if let Ok(v) = row.try_get::<Value, _>(col_name) {
67        return v;
68    }
69
70    // Try common scalar types
71    if let Ok(v) = row.try_get::<String, _>(col_name) {
72        return Value::String(v);
73    }
74    if let Ok(v) = row.try_get::<i64, _>(col_name) {
75        return Value::Number(v.into());
76    }
77    if let Ok(v) = row.try_get::<i32, _>(col_name) {
78        return Value::Number(v.into());
79    }
80    if let Ok(v) = row.try_get::<i16, _>(col_name) {
81        return Value::Number(v.into());
82    }
83    // UNSIGNED integer columns (#264). sqlx-mysql treats UNSIGNED as a
84    // distinct type from the signed decoders above, so without these arms
85    // UNSIGNED columns fall through to the `bool` probe (TINYINT UNSIGNED ->
86    // bool) or to the `Null` fall-through (larger UNSIGNED -> null), silently
87    // corrupting every unsigned column including UNSIGNED primary keys.
88    //
89    // These are placed *after* the signed probes so a signed column always
90    // matches a signed arm first, and *before* `bool`/`f64`/`f32` so a
91    // `TINYINT UNSIGNED` decodes as a number rather than a bool (MySQL's
92    // boolean is `TINYINT(1)`). `u64` fits `serde_json::Number` exactly, so
93    // BIGINT UNSIGNED values above `i64::MAX` round-trip losslessly.
94    if let Ok(v) = row.try_get::<u64, _>(col_name) {
95        return Value::Number(v.into());
96    }
97    if let Ok(v) = row.try_get::<u32, _>(col_name) {
98        return Value::Number(v.into());
99    }
100    if let Ok(v) = row.try_get::<u16, _>(col_name) {
101        return Value::Number(v.into());
102    }
103    if let Ok(v) = row.try_get::<u8, _>(col_name) {
104        return Value::Number(v.into());
105    }
106    if let Ok(v) = row.try_get::<f64, _>(col_name) {
107        return serde_json::Number::from_f64(v)
108            .map(Value::Number)
109            .unwrap_or(Value::Null);
110    }
111    if let Ok(v) = row.try_get::<f32, _>(col_name) {
112        return serde_json::Number::from_f64(v as f64)
113            .map(Value::Number)
114            .unwrap_or(Value::Null);
115    }
116    if let Ok(v) = row.try_get::<bool, _>(col_name) {
117        return Value::Bool(v);
118    }
119
120    // Richer types that would otherwise silently decode to Null (#78/#43).
121    if let Ok(v) =
122        row.try_get::<sqlx::types::chrono::DateTime<sqlx::types::chrono::Utc>, _>(col_name)
123    {
124        return Value::String(v.to_rfc3339());
125    }
126    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDateTime, _>(col_name) {
127        return Value::String(v.to_string());
128    }
129    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveDate, _>(col_name) {
130        return Value::String(v.to_string());
131    }
132    if let Ok(v) = row.try_get::<sqlx::types::chrono::NaiveTime, _>(col_name) {
133        return Value::String(v.to_string());
134    }
135    // DECIMAL → string, preserving exact precision.
136    if let Ok(v) = row.try_get::<sqlx::types::BigDecimal, _>(col_name) {
137        return Value::String(v.to_string());
138    }
139    // BLOB / BINARY → base64.
140    if let Ok(v) = row.try_get::<Vec<u8>, _>(col_name) {
141        use base64::Engine as _;
142        return Value::String(base64::engine::general_purpose::STANDARD.encode(v));
143    }
144
145    Value::Null
146}
147
148/// Build the effective SQL query and ordered context-bind values for a given
149/// parent context. Returns the literal query when there is no context.
150fn resolve_query(
151    config: &MysqlSourceConfig,
152    context: &std::collections::HashMap<String, Value>,
153) -> (String, Vec<Value>) {
154    if context.is_empty() {
155        (config.query.clone(), Vec::new())
156    } else {
157        faucet_core::util::substitute_context_bind_params(&config.query, context, 1, |_| {
158            "?".to_string()
159        })
160    }
161}
162
163/// How a numeric bind value should be bound onto a sqlx query.
164///
165/// Classifying *before* binding keeps the integer/float decision in one pure,
166/// unit-testable place and — critically — binds any integer in
167/// `[i64::MIN, i64::MAX]` as an exact `i64` rather than an `f64`. Binding an
168/// integer above `2^53` as `f64` silently rounds it (audit F38), so a large
169/// 64-bit id threaded into `WHERE id = ?` would compare against the *wrong*
170/// value and return wrong rows.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum NumberBind {
173    /// Exact `i64` — covers every integer in `[i64::MIN, i64::MAX]`.
174    I64,
175    /// Value above `i64::MAX`; bind as `u64` (MySQL has native UNSIGNED).
176    U64,
177    /// Genuine floating-point value — bind as `f64`.
178    F64,
179}
180
181/// Classify a JSON number into the bind category to use.
182///
183/// `is_i64()` losslessly covers `[i64::MIN, i64::MAX]` (including the
184/// `(2^53, i64::MAX]` range that `f64` would round); `is_u64()` covers values
185/// above `i64::MAX`; everything else is a real float.
186fn classify_number(n: &serde_json::Number) -> NumberBind {
187    if n.is_i64() {
188        NumberBind::I64
189    } else if n.is_u64() {
190        NumberBind::U64
191    } else {
192        NumberBind::F64
193    }
194}
195
196/// Apply context-derived bind values onto a sqlx query.
197fn bind_params<'q>(
198    mut query: sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments>,
199    bind_values: &'q [Value],
200) -> sqlx::query::Query<'q, sqlx::MySql, sqlx::mysql::MySqlArguments> {
201    for value in bind_values {
202        query = match value {
203            Value::String(s) => query.bind(s.clone()),
204            Value::Number(n) => match classify_number(n) {
205                // `unwrap()` is sound: the classifier proves the predicate.
206                NumberBind::I64 => query.bind(n.as_i64().unwrap()),
207                // MySQL has a native UNSIGNED BIGINT type, so bind the `u64`
208                // directly — values above `i64::MAX` round-trip losslessly.
209                NumberBind::U64 => query.bind(n.as_u64().unwrap()),
210                NumberBind::F64 => query.bind(n.as_f64().unwrap_or(0.0)),
211            },
212            Value::Bool(b) => query.bind(*b),
213            Value::Null => query.bind(None::<String>),
214            _ => query.bind(value.to_string()),
215        };
216    }
217    query
218}
219
220/// Convert a single `MySqlRow` into a JSON object whose keys are the row's
221/// column names.
222fn row_to_json(row: &sqlx::mysql::MySqlRow) -> Value {
223    let mut map = serde_json::Map::new();
224    for col in row.columns() {
225        let name = col.name().to_string();
226        let value = mysql_value_to_json(row, &name);
227        map.insert(name, value);
228    }
229    Value::Object(map)
230}
231
232#[async_trait]
233impl faucet_core::Source for MysqlSource {
234    async fn fetch_with_context(
235        &self,
236        context: &std::collections::HashMap<String, serde_json::Value>,
237    ) -> Result<Vec<Value>, FaucetError> {
238        let (query_str, bind_values) = resolve_query(&self.config, context);
239        let query_str = self.shard_wrap(query_str);
240        let query = bind_params(sqlx::query(&query_str), &bind_values);
241
242        let rows = query
243            .fetch_all(&self.pool)
244            .await
245            .map_err(|e| FaucetError::Config(format!("MySQL query failed: {e}")))?;
246
247        let records: Vec<Value> = rows.iter().map(row_to_json).collect();
248        tracing::info!(rows = records.len(), query = %self.config.query, "MySQL source fetch complete");
249        Ok(records)
250    }
251
252    /// Stream rows from the underlying sqlx cursor without buffering the full
253    /// result set. Each emitted [`StreamPage`] holds up to
254    /// [`MysqlSourceConfig::batch_size`] rows.
255    ///
256    /// The trait-level `batch_size` argument is ignored in favour of the
257    /// config field — the config is the user-facing knob the README
258    /// documents, and routing the pipeline-supplied hint through it would
259    /// silently override an explicit config value.
260    ///
261    /// `batch_size = 0` drains the entire cursor into a single page. The
262    /// mysql query source has no incremental-replication mode today, so
263    /// every emitted page carries `bookmark: None`.
264    fn stream_pages<'a>(
265        &'a self,
266        context: &'a std::collections::HashMap<String, Value>,
267        _batch_size: usize,
268    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
269        let batch_size = self.config.batch_size;
270
271        Box::pin(async_stream::try_stream! {
272            let (query_str, bind_values) = resolve_query(&self.config, context);
273            let query_str = self.shard_wrap(query_str);
274            let query = bind_params(sqlx::query(&query_str), &bind_values);
275
276            let mut rows = query.fetch(&self.pool);
277            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
278            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
279            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
280            let mut total = 0usize;
281
282            while let Some(row) = rows
283                .try_next()
284                .await
285                .map_err(|e| FaucetError::Config(format!("MySQL query failed: {e}")))?
286            {
287                buffer.push(row_to_json(&row));
288                if buffer.len() >= chunk {
289                    let page = std::mem::replace(&mut buffer, Vec::with_capacity(initial_capacity));
290                    total += page.len();
291                    yield StreamPage { records: page, bookmark: None };
292                }
293            }
294            if !buffer.is_empty() {
295                total += buffer.len();
296                yield StreamPage { records: buffer, bookmark: None };
297            }
298
299            tracing::info!(
300                rows = total,
301                batch_size,
302                query = %self.config.query,
303                "MySQL source stream complete",
304            );
305        })
306    }
307
308    fn config_schema(&self) -> serde_json::Value {
309        serde_json::to_value(faucet_core::schema_for!(MysqlSourceConfig))
310            .expect("schema serialization")
311    }
312
313    fn dataset_uri(&self) -> String {
314        format!(
315            "{}?query={}",
316            faucet_core::redact_uri_credentials(&self.config.connection_url),
317            self.config.query
318        )
319    }
320
321    /// Shardable when a [`ShardConfig`](crate::config::ShardConfig) is set.
322    fn is_shardable(&self) -> bool {
323        self.config.shard.is_some()
324    }
325
326    /// Enumerate contiguous primary-key range shards by computing the `key`
327    /// column's `MIN`/`MAX` over the (unsharded) base query and splitting that
328    /// range into ~`target` slices. Returns a single whole-dataset shard when no
329    /// `shard` config is set or the result set is empty.
330    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
331        let Some(shard_cfg) = &self.config.shard else {
332            return Ok(vec![ShardSpec::whole()]);
333        };
334
335        let bounds_sql = pk_bounds_query(
336            &self.config.query,
337            &quote_ident_mysql(&shard_cfg.key),
338            "SIGNED",
339        );
340        let row = sqlx::query(&bounds_sql)
341            .fetch_one(&self.pool)
342            .await
343            .map_err(|e| {
344                FaucetError::Source(format!(
345                    "mysql: failed to compute shard bounds for key {:?} \
346                     (it must be an integer-typed column): {e}",
347                    shard_cfg.key
348                ))
349            })?;
350
351        let lo: Option<i64> = row
352            .try_get("lo")
353            .map_err(|e| FaucetError::Source(format!("mysql: shard bounds decode failed: {e}")))?;
354        let hi: Option<i64> = row
355            .try_get("hi")
356            .map_err(|e| FaucetError::Source(format!("mysql: shard bounds decode failed: {e}")))?;
357        Ok(pk_shards_from_bounds(&shard_cfg.key, lo, hi, target))
358    }
359
360    /// Narrow this source to a single PK-range shard. The whole-dataset shard
361    /// clears any applied range (streams the full query).
362    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
363        *self.applied_shard.lock().expect("shard mutex poisoned") = parse_pk_shard(shard, "mysql")?;
364        Ok(())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use faucet_core::shard::plan_pk_shards;
372
373    #[tokio::test]
374    async fn new_rejects_out_of_range_batch_size() {
375        let mut config = MysqlSourceConfig::new("mysql://localhost/test", "SELECT 1");
376        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
377        match MysqlSource::new(config).await {
378            Err(faucet_core::FaucetError::Config(m)) => {
379                assert!(m.contains("batch_size"), "got: {m}")
380            }
381            _ => panic!("expected a batch_size Config error"),
382        }
383    }
384
385    // dataset_uri is a pure-config method; the source requires a live DB to
386    // construct so we verify the credential-stripping logic directly.
387    #[test]
388    fn dataset_uri_strips_credentials() {
389        let redacted = faucet_core::redact_uri_credentials("mysql://u:p@h:3306/db");
390        let uri = format!("{}?query={}", redacted, "SELECT 1");
391        assert_eq!(uri, "mysql://h:3306/db?query=SELECT 1");
392    }
393
394    // ── F38: numeric bind classification (precision-safe) ───────────────────
395
396    fn num(v: serde_json::Value) -> serde_json::Number {
397        match v {
398            serde_json::Value::Number(n) => n,
399            _ => panic!("not a number"),
400        }
401    }
402
403    #[test]
404    fn classify_small_int_is_i64() {
405        assert_eq!(
406            classify_number(&num(serde_json::json!(42))),
407            NumberBind::I64
408        );
409        assert_eq!(
410            classify_number(&num(serde_json::json!(-7))),
411            NumberBind::I64
412        );
413        assert_eq!(classify_number(&num(serde_json::json!(0))), NumberBind::I64);
414    }
415
416    #[test]
417    fn classify_above_2_pow_53_stays_i64_not_f64() {
418        // 2^53 + 1 must NOT be bound as f64 (which would round it). It is a
419        // valid i64, so it must classify as I64.
420        let v = 9_007_199_254_740_993i64; // 2^53 + 1
421        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::I64);
422    }
423
424    #[test]
425    fn classify_i64_boundaries_are_i64() {
426        assert_eq!(
427            classify_number(&num(serde_json::json!(i64::MAX))),
428            NumberBind::I64
429        );
430        assert_eq!(
431            classify_number(&num(serde_json::json!(i64::MIN))),
432            NumberBind::I64
433        );
434    }
435
436    #[test]
437    fn classify_above_i64_max_is_u64() {
438        let v: u64 = i64::MAX as u64 + 1;
439        assert_eq!(classify_number(&num(serde_json::json!(v))), NumberBind::U64);
440        assert_eq!(
441            classify_number(&num(serde_json::json!(u64::MAX))),
442            NumberBind::U64
443        );
444    }
445
446    #[test]
447    fn classify_float_is_f64() {
448        assert_eq!(
449            classify_number(&num(serde_json::json!(3.5))),
450            NumberBind::F64
451        );
452    }
453
454    // ── PK-range sharding (Mode B, #262) ─────────────────────────────────────
455
456    #[test]
457    fn quote_ident_mysql_backticks_and_escapes() {
458        assert_eq!(quote_ident_mysql("id"), "`id`");
459        // Embedded backticks are doubled — identifier injection is inert.
460        assert_eq!(quote_ident_mysql("we`ird"), "`we``ird`");
461    }
462
463    #[test]
464    fn shard_wrap_uses_backtick_quoting() {
465        let spec = faucet_core::shard::ShardSpec::new(
466            "1",
467            serde_json::json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
468        );
469        let bounds = PkShardBounds::from_spec(&spec).unwrap();
470        let sql = bounds.wrap("SELECT * FROM t", quote_ident_mysql);
471        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"), "{sql}");
472        assert!(sql.contains("`id` >= 100"), "backtick-quoted key: {sql}");
473        assert!(sql.contains("`id` < 200"), "half-open upper bound: {sql}");
474    }
475
476    #[test]
477    fn last_shard_wrap_covers_null_keys() {
478        let shards = plan_pk_shards("id", 0, 99, 3);
479        let last = PkShardBounds::from_spec(shards.last().unwrap()).unwrap();
480        let sql = last.wrap("SELECT * FROM t", quote_ident_mysql);
481        assert!(
482            sql.contains("`id` IS NULL"),
483            "last shard must match NULL keys: {sql}"
484        );
485    }
486
487    /// Build a source over a lazy pool (no server needed) so the shard glue —
488    /// `apply_shard`, `shard_wrap`, and `enumerate_shards`' non-I/O branches —
489    /// is testable without Docker.
490    fn lazy_source(config: MysqlSourceConfig) -> MysqlSource {
491        let pool = MySqlPoolOptions::new()
492            // Fail fast at first checkout — these tests never reach a server.
493            .acquire_timeout(std::time::Duration::from_millis(200))
494            .connect_lazy(&config.connection_url)
495            .expect("lazy pool");
496        MysqlSource {
497            config,
498            pool,
499            applied_shard: Mutex::new(None),
500        }
501    }
502
503    #[tokio::test]
504    async fn apply_shard_then_shard_wrap_narrows_query() {
505        use faucet_core::Source as _;
506        let mut config = MysqlSourceConfig::new("mysql://root@127.0.0.1:1/db", "SELECT * FROM t");
507        config.shard = Some(crate::config::ShardConfig { key: "id".into() });
508        let source = lazy_source(config);
509        assert!(source.is_shardable());
510
511        // No shard applied / whole shard applied → query passes through.
512        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
513        source
514            .apply_shard(&faucet_core::ShardSpec::whole())
515            .await
516            .unwrap();
517        assert_eq!(source.shard_wrap("SELECT 1".into()), "SELECT 1");
518
519        // A real shard narrows with backtick quoting.
520        let spec = &plan_pk_shards("id", 0, 99, 2)[0];
521        source.apply_shard(spec).await.unwrap();
522        let wrapped = source.shard_wrap("SELECT * FROM t".into());
523        assert!(wrapped.contains("`id`"), "got: {wrapped}");
524        assert!(wrapped.contains("_faucet_shard"), "got: {wrapped}");
525
526        // Malformed descriptor is rejected.
527        let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "key": "id" }));
528        assert!(source.apply_shard(&bad).await.is_err());
529    }
530
531    #[tokio::test]
532    async fn enumerate_shards_without_config_is_whole_and_with_config_needs_db() {
533        use faucet_core::Source as _;
534        // No `shard` config → single whole shard, no I/O.
535        let plain = lazy_source(MysqlSourceConfig::new(
536            "mysql://root@127.0.0.1:1/db",
537            "SELECT 1",
538        ));
539        assert!(!plain.is_shardable());
540        let shards = plain.enumerate_shards(4).await.unwrap();
541        assert_eq!(shards.len(), 1);
542        assert!(shards[0].is_whole());
543
544        // With config, enumeration must reach the (unreachable) server → the
545        // bounds-probe error path surfaces as FaucetError::Source.
546        let mut config = MysqlSourceConfig::new("mysql://root@127.0.0.1:1/db", "SELECT 1");
547        config.shard = Some(crate::config::ShardConfig { key: "id".into() });
548        let sharded = lazy_source(config);
549        let err = sharded.enumerate_shards(4).await.unwrap_err();
550        assert!(
551            err.to_string().contains("shard bounds"),
552            "expected bounds-probe error, got: {err}"
553        );
554    }
555}