Skip to main content

faucet_sink_mysql/
sink.rs

1//! MySQL sink implementation.
2
3use crate::config::{MysqlColumnMapping, MysqlSinkConfig};
4use async_trait::async_trait;
5use faucet_core::{FaucetError, SchemaEvolution, SqlBaseType, json_schema_base_type};
6use serde_json::Value;
7use sqlx::mysql::MySqlPoolOptions;
8use sqlx::{MySqlConnection, MySqlPool, Row};
9
10/// A sink that writes JSON records to a MySQL table.
11pub struct MysqlSink {
12    config: MysqlSinkConfig,
13    pool: MySqlPool,
14}
15
16/// Quote a MySQL identifier using backticks.
17///
18/// Wraps the name in backticks and escapes any embedded backticks by doubling
19/// them, per MySQL convention.
20fn quote_ident_mysql(name: &str) -> String {
21    format!("`{}`", name.replace('`', "``"))
22}
23
24/// Map a [`SqlBaseType`] to the MySQL type keyword used when adding/widening a
25/// column during schema evolution (issue #194). Integers always widen to
26/// `BIGINT` and floats to `DOUBLE` so a later, wider value never overflows a
27/// narrower column. `Text` maps to `LONGTEXT` (the widest text type, so a long
28/// value never truncates) and `Json` to MySQL's native `JSON`.
29fn mysql_keyword(t: SqlBaseType) -> &'static str {
30    match t {
31        SqlBaseType::Integer => "BIGINT",
32        SqlBaseType::Double => "DOUBLE",
33        SqlBaseType::Boolean => "TINYINT(1)",
34        SqlBaseType::Text => "LONGTEXT",
35        SqlBaseType::Json => "JSON",
36    }
37}
38
39/// `ALTER TABLE <table> ADD COLUMN `col` <kw>` — column addition.
40///
41/// MySQL (pre-8.0.x) has no `ADD COLUMN IF NOT EXISTS`, so idempotency is
42/// achieved by the caller pre-checking the existing column set and only
43/// emitting this for columns not already present. `table` is the already-quoted
44/// table reference.
45fn build_add_column_sql(table: &str, col: &str, t: SqlBaseType) -> String {
46    format!(
47        "ALTER TABLE {table} ADD COLUMN {} {}",
48        quote_ident_mysql(col),
49        mysql_keyword(t)
50    )
51}
52
53/// `ALTER TABLE <table> MODIFY COLUMN `col` <kw>` — widen an existing column's
54/// type. Naturally idempotent (re-running the same MODIFY is a no-op). `table`
55/// is the already-quoted table reference.
56fn build_modify_column_sql(table: &str, col: &str, t: SqlBaseType) -> String {
57    format!(
58        "ALTER TABLE {table} MODIFY COLUMN {} {}",
59        quote_ident_mysql(col),
60        mysql_keyword(t)
61    )
62}
63
64/// Map a MySQL `INFORMATION_SCHEMA.COLUMNS.DATA_TYPE` value (lowercase, no
65/// precision — e.g. `bigint`, `double`, `json`, `varchar`) back to a JSON-Schema
66/// type fragment so [`MysqlSink::current_schema`] round-trips with
67/// [`faucet_core::diff_schema`]. `nullable` reflects `IS_NULLABLE = 'YES'`.
68///
69/// Note: `INFORMATION_SCHEMA.DATA_TYPE` returns bare `tinyint` without the
70/// `(1)` precision, so a `TINYINT(1)` (conventionally boolean) is
71/// indistinguishable from a real `TINYINT` — both map to `integer` for safety.
72fn mysql_data_type_to_json_schema(data_type: &str, nullable: bool) -> Value {
73    let base = match data_type {
74        "bigint" | "int" | "integer" | "smallint" | "mediumint" | "tinyint" => "integer",
75        "double" | "float" | "decimal" | "numeric" => "number",
76        "json" => "object",
77        _ => "string",
78    };
79    if nullable {
80        serde_json::json!({ "type": [base, "null"] })
81    } else {
82        serde_json::json!({ "type": base })
83    }
84}
85
86/// Build the `ON DUPLICATE KEY UPDATE …` tail for an upsert INSERT.
87///
88/// MySQL's `ON DUPLICATE KEY UPDATE` does not name a conflict target — it
89/// relies on the table's existing PRIMARY or UNIQUE key. Non-key columns are
90/// set from `VALUES(col)`. If every column is a key column there is nothing to
91/// update, so a self-assignment no-op on the first key column is emitted to
92/// keep the statement syntactically valid.
93/// Decide whether the configured upsert/delete `key` exactly corresponds to one
94/// of the target table's PRIMARY/UNIQUE indexes.
95///
96/// MySQL's `INSERT … ON DUPLICATE KEY UPDATE` does not name a conflict target —
97/// it resolves on *any* unique index present on the table. The unified
98/// write-mode contract, however, treats the configured `key` as the
99/// authoritative conflict target (`plan_writes` dedups and routes by exactly
100/// that key). If the configured `key` does not match a real unique index, MySQL
101/// would silently resolve the conflict on a *different* index, producing wrong
102/// upsert results that the user cannot detect (finding F33). This check lets the
103/// sink fail fast at construction instead.
104///
105/// `unique_indexes` is the set of the table's PRIMARY/UNIQUE indexes, each given
106/// as the full set of its column names. `key` is the configured key columns.
107/// The comparison is **order-insensitive** (a UNIQUE index on `(a, b)` matches a
108/// key of `[b, a]`) and requires the **full** column set of some index to match
109/// the key exactly — a prefix, subset, or superset does **not** match, because
110/// `ON DUPLICATE KEY UPDATE` would then trigger on a broader or narrower index
111/// than the one the pipeline deduped on.
112fn key_matches_unique_index(
113    unique_indexes: &[std::collections::BTreeSet<String>],
114    key: &[String],
115) -> bool {
116    if key.is_empty() {
117        return false;
118    }
119    let key_set: std::collections::BTreeSet<String> = key.iter().cloned().collect();
120    unique_indexes.contains(&key_set)
121}
122
123fn on_duplicate_clause(key: &[String], all_cols: &[String]) -> String {
124    let updates: Vec<String> = all_cols
125        .iter()
126        .filter(|c| !key.iter().any(|k| k == *c))
127        .map(|c| {
128            let q = quote_ident_mysql(c);
129            format!("{q} = VALUES({q})")
130        })
131        .collect();
132    if updates.is_empty() {
133        let q = quote_ident_mysql(&key[0]);
134        format!("ON DUPLICATE KEY UPDATE {q} = {q}")
135    } else {
136        format!("ON DUPLICATE KEY UPDATE {}", updates.join(", "))
137    }
138}
139
140impl MysqlSink {
141    /// Create a new MySQL sink. Establishes a connection pool.
142    pub async fn new(config: MysqlSinkConfig) -> Result<Self, FaucetError> {
143        config.write.validate()?;
144        if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
145            && !matches!(config.column_mapping, MysqlColumnMapping::AutoMap)
146        {
147            return Err(FaucetError::Config(
148                "mysql sink: write_mode upsert/delete requires column_mapping: auto_map \
149                 (key columns must be real columns, not inside a JSON blob)"
150                    .into(),
151            ));
152        }
153
154        let pool = MySqlPoolOptions::new()
155            .max_connections(config.max_connections)
156            .connect(&config.connection_url)
157            .await
158            .map_err(|e| FaucetError::Sink(format!("MySQL connection failed: {e}")))?;
159
160        let sink = Self { config, pool };
161
162        // For upsert/delete, MySQL's anonymous `ON DUPLICATE KEY UPDATE` /
163        // `DELETE … WHERE (key) IN (…)` only resolves correctly when the
164        // configured `key` matches a real PRIMARY/UNIQUE index. Assert that here
165        // so a silent mismatch (finding F33) fails fast at construction.
166        if !matches!(sink.config.write.write_mode, faucet_core::WriteMode::Append) {
167            sink.assert_key_is_unique_index().await?;
168        }
169
170        Ok(sink)
171    }
172
173    /// Read the target table's PRIMARY/UNIQUE indexes from
174    /// `INFORMATION_SCHEMA.STATISTICS`, each as the full set of its column names.
175    ///
176    /// `STATISTICS` lists one row per index column; only non-unique-flag-zero
177    /// rows (`NON_UNIQUE = 0`) are unique indexes (the PRIMARY KEY is reported as
178    /// an index named `PRIMARY` and is also `NON_UNIQUE = 0`). Returns an empty
179    /// `Vec` when the table does not exist or has no unique indexes — the caller
180    /// decides what to do with an absent table.
181    ///
182    /// Thin I/O shim; the pure decision is [`key_matches_unique_index`].
183    async fn read_unique_indexes(
184        &self,
185    ) -> Result<Vec<std::collections::BTreeSet<String>>, FaucetError> {
186        // INFORMATION_SCHEMA string columns use a binary collation that sqlx
187        // decodes as Vec<u8>; CAST to CHAR so they decode as String.
188        let rows = sqlx::query(
189            "SELECT CAST(INDEX_NAME AS CHAR) AS INDEX_NAME, \
190                    CAST(COLUMN_NAME AS CHAR) AS COLUMN_NAME \
191             FROM INFORMATION_SCHEMA.STATISTICS \
192             WHERE TABLE_NAME = ? AND TABLE_SCHEMA = DATABASE() AND NON_UNIQUE = 0 \
193             ORDER BY INDEX_NAME, SEQ_IN_INDEX",
194        )
195        .bind(&self.config.table_name)
196        .fetch_all(&self.pool)
197        .await
198        .map_err(|e| FaucetError::Sink(format!("failed to query table indexes: {e}")))?;
199
200        let mut by_index: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
201            std::collections::BTreeMap::new();
202        for row in &rows {
203            let index_name: String = row.get("INDEX_NAME");
204            let column_name: String = row.get("COLUMN_NAME");
205            by_index.entry(index_name).or_default().insert(column_name);
206        }
207        Ok(by_index.into_values().collect())
208    }
209
210    /// Assert that the configured `key` exactly matches a PRIMARY/UNIQUE index on
211    /// the target table, or fail with a clear typed [`FaucetError::Config`].
212    ///
213    /// **Table-absent behaviour:** if the table has no unique indexes — which is
214    /// the case when it does not exist yet — the assertion is **skipped** with a
215    /// warning, matching the rest of this sink which auto-discovers columns and
216    /// lets the first write surface a missing-table error. (`current_schema`
217    /// likewise returns `None` for an absent table.) The check is a guard against
218    /// a *mismatched* index on an existing table, not a table-existence preflight.
219    async fn assert_key_is_unique_index(&self) -> Result<(), FaucetError> {
220        let unique_indexes = self.read_unique_indexes().await?;
221        if unique_indexes.is_empty() {
222            tracing::warn!(
223                table = %self.config.table_name,
224                "mysql sink: no PRIMARY/UNIQUE index found on target table (it may not exist \
225                 yet); skipping upsert key validation — the first write will surface a \
226                 missing-table or missing-constraint error"
227            );
228            return Ok(());
229        }
230        if !key_matches_unique_index(&unique_indexes, &self.config.write.key) {
231            let available: Vec<String> = unique_indexes
232                .iter()
233                .map(|idx| {
234                    let mut cols: Vec<&str> = idx.iter().map(String::as_str).collect();
235                    cols.sort_unstable();
236                    format!("({})", cols.join(", "))
237                })
238                .collect();
239            return Err(FaucetError::Config(format!(
240                "mysql sink: write_mode {} requires `key` {:?} to exactly match a PRIMARY KEY or \
241                 UNIQUE index on table '{}' — MySQL's `ON DUPLICATE KEY UPDATE` resolves on the \
242                 table's real unique indexes, so an unmatched key would silently upsert on the \
243                 wrong index. Existing unique indexes: {}",
244                self.config.write.write_mode.as_str(),
245                self.config.write.key,
246                self.config.table_name,
247                available.join(", "),
248            )));
249        }
250        Ok(())
251    }
252
253    /// Insert a batch of records using JSON column mode.
254    ///
255    /// Executes on the provided connection (a bare pool connection for
256    /// `write_batch`, or a `&mut *tx` transaction for `write_batch_idempotent`).
257    /// Uses a single multi-row INSERT for efficiency.
258    async fn insert_json(
259        &self,
260        conn: &mut MySqlConnection,
261        records: &[Value],
262        column: &str,
263    ) -> Result<usize, FaucetError> {
264        if records.is_empty() {
265            return Ok(0);
266        }
267
268        // Build multi-row INSERT: INSERT INTO t (col) VALUES (?), (?), ...
269        let placeholders: Vec<&str> = records.iter().map(|_| "(?)").collect();
270        let insert_sql = format!(
271            "INSERT INTO {} ({}) VALUES {}",
272            quote_ident_mysql(&self.config.table_name),
273            quote_ident_mysql(column),
274            placeholders.join(", ")
275        );
276
277        let mut q = sqlx::query(&insert_sql);
278        for record in records {
279            let json_str = serde_json::to_string(record)
280                .map_err(|e| FaucetError::Sink(format!("failed to serialize record: {e}")))?;
281            q = q.bind(json_str);
282        }
283
284        q.execute(&mut *conn)
285            .await
286            .map_err(|e| FaucetError::Sink(format!("MySQL insert failed: {e}")))?;
287
288        Ok(records.len())
289    }
290
291    /// Core auto-map insert logic, optionally appending an `ON DUPLICATE KEY
292    /// UPDATE …` clause when `conflict_key` is `Some`.
293    ///
294    /// Discovers column names from `INFORMATION_SCHEMA.COLUMNS` and maps
295    /// top-level JSON fields to columns. Executes on the provided connection
296    /// (a bare pool connection for `write_batch`, or `&mut *tx` for
297    /// transactional paths). Uses sub-chunked multi-row INSERTs.
298    ///
299    /// When `conflict_key` is `Some(key)`, each sub-chunk's INSERT is given an
300    /// `ON DUPLICATE KEY UPDATE …` tail so it upserts by the existing PRIMARY
301    /// or UNIQUE key (last-write-wins within the batch is handled by the
302    /// planner's dedup, so a single sub-chunk never double-hits the same
303    /// conflict target).
304    async fn insert_auto_map_with_conflict(
305        &self,
306        conn: &mut MySqlConnection,
307        records: &[Value],
308        conflict_key: Option<&[String]>,
309    ) -> Result<usize, FaucetError> {
310        if records.is_empty() {
311            return Ok(0);
312        }
313
314        // Get column names from the table.
315        let columns: Vec<String> = sqlx::query(
316            "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND TABLE_SCHEMA = DATABASE() ORDER BY ORDINAL_POSITION"
317        )
318        .bind(&self.config.table_name)
319        .fetch_all(&mut *conn)
320        .await
321        .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
322        .iter()
323        .map(|row| row.get::<String, _>("COLUMN_NAME"))
324        .collect();
325
326        if columns.is_empty() {
327            return Err(FaucetError::Sink(format!(
328                "table '{}' has no columns or does not exist",
329                self.config.table_name
330            )));
331        }
332
333        // Pre-validate all records and collect matched column values. The
334        // INSERT column set is the UNION of table columns present in ANY record
335        // (in declared table order), not just the first record's keys —
336        // otherwise a field present only in a later record of the batch would be
337        // silently dropped (audit #146 H1). A row missing a unioned column binds
338        // SQL NULL.
339        let mut matched_rows: Vec<Vec<(&String, &Value)>> = Vec::with_capacity(records.len());
340        let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
341
342        for record in records {
343            let obj = record
344                .as_object()
345                .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
346
347            let matching: Vec<(&String, &Value)> = columns
348                .iter()
349                .filter_map(|col| obj.get(col).map(|v| (col, v)))
350                .collect();
351
352            if matching.is_empty() {
353                tracing::warn!(
354                    record_keys = ?obj.keys().collect::<Vec<_>>(),
355                    table_columns = ?columns,
356                    "record has no keys matching table columns, skipping"
357                );
358                continue;
359            }
360
361            for (c, _) in &matching {
362                used.insert(c.as_str());
363            }
364            matched_rows.push(matching);
365        }
366
367        if matched_rows.is_empty() {
368            return Ok(0);
369        }
370
371        // Table columns (in declared order) that appear in at least one record.
372        let insert_columns: Vec<String> = columns
373            .iter()
374            .filter(|c| used.contains(c.as_str()))
375            .cloned()
376            .collect();
377
378        let num_cols = insert_columns.len();
379        let num_rows = matched_rows.len();
380        let col_names: Vec<String> = insert_columns
381            .iter()
382            .map(|c| quote_ident_mysql(c))
383            .collect();
384
385        // MySQL caps prepared-statement placeholders at 65535. A multi-row
386        // INSERT binds `rows × num_cols`, so a wide table at a large batch_size
387        // overflows and fails at runtime; split into sub-INSERTs of at most
388        // floor(MAX / num_cols) rows (audit #146 H14 — postgres/sqlite/mssql
389        // already sub-chunk this way).
390        const MAX_MYSQL_PARAMS: usize = 65535;
391        let max_rows_per_insert = (MAX_MYSQL_PARAMS / num_cols).max(1);
392
393        for sub in matched_rows.chunks(max_rows_per_insert) {
394            // Build multi-row VALUES clause: (?, ?), (?, ?), ...
395            let row_placeholder = format!("({})", vec!["?"; num_cols].join(", "));
396            let value_tuples: Vec<&str> =
397                (0..sub.len()).map(|_| row_placeholder.as_str()).collect();
398            let base_query = format!(
399                "INSERT INTO {} ({}) VALUES {}",
400                quote_ident_mysql(&self.config.table_name),
401                col_names.join(", "),
402                value_tuples.join(", ")
403            );
404            let query = match conflict_key {
405                Some(key) => format!("{base_query} {}", on_duplicate_clause(key, &insert_columns)),
406                None => base_query,
407            };
408
409            let mut q = sqlx::query(&query);
410            for matched in sub {
411                for col in &insert_columns {
412                    let val = matched.iter().find(|(c, _)| *c == col).map(|(_, v)| *v);
413                    // Bind native MySQL types. Binding every value as a JSON string
414                    // (the old behaviour) stored `"Bob"` with embedded quotes,
415                    // turned `true` into the text "true", and bound the literal
416                    // text "null" for absent columns instead of SQL NULL (#78/#4).
417                    q = match val {
418                        None | Some(Value::Null) => q.bind(None::<String>),
419                        Some(Value::Bool(b)) => q.bind(*b),
420                        Some(Value::Number(n)) => {
421                            if let Some(i) = n.as_i64() {
422                                q.bind(i)
423                            } else if let Some(f) = n.as_f64() {
424                                q.bind(f)
425                            } else {
426                                // u64 above i64::MAX — preserve exact text.
427                                q.bind(n.to_string())
428                            }
429                        }
430                        Some(Value::String(s)) => q.bind(s.clone()),
431                        // Arrays/objects have no scalar SQL representation — store
432                        // their JSON text (suitable for TEXT / JSON columns).
433                        Some(v) => q.bind(v.to_string()),
434                    };
435                }
436            }
437
438            q.execute(&mut *conn)
439                .await
440                .map_err(|e| FaucetError::Sink(format!("MySQL insert failed: {e}")))?;
441        }
442
443        Ok(num_rows)
444    }
445
446    /// Auto-map insert with plain append semantics (no `ON DUPLICATE KEY`
447    /// clause). Thin wrapper over
448    /// [`insert_auto_map_with_conflict`](Self::insert_auto_map_with_conflict)
449    /// so the append path and `write_batch_idempotent` keep their original
450    /// signature.
451    async fn insert_auto_map(
452        &self,
453        conn: &mut MySqlConnection,
454        records: &[Value],
455    ) -> Result<usize, FaucetError> {
456        self.insert_auto_map_with_conflict(conn, records, None)
457            .await
458    }
459
460    /// Delete rows whose key columns match any of `deletes`, using
461    /// `DELETE FROM t WHERE (k1, …) IN ((?, …), …)`, chunked at MySQL's
462    /// 65535-placeholder limit. Runs inside the caller's transaction.
463    async fn delete_by_keys(
464        &self,
465        conn: &mut MySqlConnection,
466        deletes: &[faucet_core::KeyTuple],
467    ) -> Result<usize, FaucetError> {
468        if deletes.is_empty() {
469            return Ok(0);
470        }
471        let key = &self.config.write.key;
472        let table_ref = quote_ident_mysql(&self.config.table_name);
473        let col_list = key
474            .iter()
475            .map(|k| quote_ident_mysql(k))
476            .collect::<Vec<_>>()
477            .join(", ");
478
479        const MAX_MYSQL_PARAMS: usize = 65535;
480        let per = (MAX_MYSQL_PARAMS / key.len().max(1)).max(1);
481        let mut total = 0usize;
482
483        for chunk in deletes.chunks(per) {
484            let tuples: Vec<String> = chunk
485                .iter()
486                .map(|_| format!("({})", vec!["?"; key.len()].join(", ")))
487                .collect();
488            let sql = format!(
489                "DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
490                tuples.join(", ")
491            );
492            let mut q = sqlx::query(&sql);
493            for kt in chunk {
494                for (_, v) in &kt.0 {
495                    // Bind native MySQL types — same logic as in the INSERT path.
496                    q = match v {
497                        Value::Null => q.bind(None::<String>),
498                        Value::Bool(b) => q.bind(*b),
499                        Value::Number(n) => {
500                            if let Some(i) = n.as_i64() {
501                                q.bind(i)
502                            } else if let Some(f) = n.as_f64() {
503                                q.bind(f)
504                            } else {
505                                q.bind(n.to_string())
506                            }
507                        }
508                        Value::String(s) => q.bind(s.clone()),
509                        other => q.bind(other.to_string()),
510                    };
511                }
512            }
513            let res = q
514                .execute(&mut *conn)
515                .await
516                .map_err(|e| FaucetError::Sink(format!("MySQL delete failed: {e}")))?;
517            total += res.rows_affected() as usize;
518        }
519        Ok(total)
520    }
521
522    /// Apply a planned upsert/delete batch inside one `BEGIN`/`COMMIT`
523    /// transaction. Upserts and deletes are wrapped together so they commit
524    /// atomically.
525    async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
526        let mut tx = self
527            .pool
528            .begin()
529            .await
530            .map_err(|e| FaucetError::Sink(format!("MySQL transaction begin failed: {e}")))?;
531
532        let mut affected = 0usize;
533        if !plan.upserts.is_empty() {
534            affected += self
535                .insert_auto_map_with_conflict(&mut tx, &plan.upserts, Some(&self.config.write.key))
536                .await?;
537        }
538        if !plan.deletes.is_empty() {
539            affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
540        }
541
542        tx.commit()
543            .await
544            .map_err(|e| FaucetError::Sink(format!("MySQL transaction commit failed: {e}")))?;
545        Ok(affected)
546    }
547
548    /// Read the live destination columns as `(name, data_type, nullable)`
549    /// tuples, in declared order, from `INFORMATION_SCHEMA.COLUMNS`. An empty
550    /// vector means the table does not exist (or has no columns).
551    ///
552    /// Shared by [`current_schema`](faucet_core::Sink::current_schema) and
553    /// [`evolve_schema`](faucet_core::Sink::evolve_schema) — the latter needs the
554    /// current set to make `ADD COLUMN` idempotent (MySQL lacks
555    /// `ADD COLUMN IF NOT EXISTS`).
556    async fn read_columns(&self) -> Result<Vec<(String, String, bool)>, FaucetError> {
557        // MySQL's INFORMATION_SCHEMA string columns are typed as a binary/blob
558        // collation, which sqlx decodes as `Vec<u8>` rather than `String`; cast
559        // each to CHAR so they decode as `String`.
560        let rows = sqlx::query(
561            "SELECT CAST(COLUMN_NAME AS CHAR) AS COLUMN_NAME, \
562                    CAST(DATA_TYPE AS CHAR) AS DATA_TYPE, \
563                    CAST(IS_NULLABLE AS CHAR) AS IS_NULLABLE \
564             FROM INFORMATION_SCHEMA.COLUMNS \
565             WHERE TABLE_NAME = ? AND TABLE_SCHEMA = DATABASE() ORDER BY ORDINAL_POSITION",
566        )
567        .bind(&self.config.table_name)
568        .fetch_all(&self.pool)
569        .await
570        .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?;
571
572        Ok(rows
573            .iter()
574            .map(|row| {
575                (
576                    row.get::<String, _>("COLUMN_NAME"),
577                    // DATA_TYPE is already lowercase per the SQL standard, but
578                    // normalize defensively.
579                    row.get::<String, _>("DATA_TYPE").to_ascii_lowercase(),
580                    row.get::<String, _>("IS_NULLABLE")
581                        .eq_ignore_ascii_case("YES"),
582                )
583            })
584            .collect())
585    }
586
587    /// Create the commit-token watermark table if it does not yet exist.
588    ///
589    /// The table holds one row per pipeline scope (state key). MySQL requires a
590    /// fixed-length column as the primary key, so `scope` is `VARCHAR(255)` and
591    /// `token` is `VARCHAR(32)` (tokens are 20-char ulids, 32 chars is ample).
592    async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
593        let sql = format!(
594            "CREATE TABLE IF NOT EXISTS {t} ({s} VARCHAR(255) PRIMARY KEY, {k} VARCHAR(32) NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)",
595            t = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
596            s = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
597            k = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
598        );
599        sqlx::query(&sql)
600            .execute(&self.pool)
601            .await
602            .map_err(|e| FaucetError::Sink(format!("MySQL commit-table create failed: {e}")))?;
603        Ok(())
604    }
605}
606
607#[async_trait]
608impl faucet_core::Sink for MysqlSink {
609    fn config_schema(&self) -> serde_json::Value {
610        serde_json::to_value(faucet_core::schema_for!(MysqlSinkConfig))
611            .expect("schema serialization")
612    }
613
614    fn dataset_uri(&self) -> String {
615        format!(
616            "{}?table={}",
617            faucet_core::redact_uri_credentials(&self.config.connection_url),
618            self.config.table_name
619        )
620    }
621
622    /// Preflight connectivity probe (`faucet doctor`).
623    ///
624    /// Acquires a connection from the existing pool and runs `SELECT 1`. This
625    /// is non-mutating and idempotent — it validates that the database is
626    /// reachable and the credentials are accepted without writing anything.
627    async fn check(
628        &self,
629        ctx: &faucet_core::check::CheckContext,
630    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
631        use faucet_core::check::{CheckReport, Probe};
632
633        let started = std::time::Instant::now();
634        let probe =
635            match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
636                .await
637            {
638                Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
639                Ok(Err(e)) => Probe::fail_hint(
640                    "auth",
641                    started.elapsed(),
642                    e.to_string(),
643                    "check connection_url / credentials / that the database is reachable",
644                ),
645                Err(_) => Probe::fail_hint(
646                    "auth",
647                    started.elapsed(),
648                    "timed out",
649                    "check connection_url / credentials / that the database is reachable",
650                ),
651            };
652        Ok(CheckReport::single(probe))
653    }
654
655    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
656        &[
657            faucet_core::WriteMode::Append,
658            faucet_core::WriteMode::Upsert,
659            faucet_core::WriteMode::Delete,
660        ]
661    }
662
663    fn dedups_by_key(&self) -> bool {
664        self.config.write.dedups_by_key()
665    }
666
667    fn supports_schema_evolution(&self) -> bool {
668        true
669    }
670
671    /// Read the live destination schema from `INFORMATION_SCHEMA.COLUMNS` as an
672    /// `infer_schema`-shaped object (`{"type":"object","properties":{…}}`), or
673    /// `None` when the target table does not exist yet (issue #194).
674    ///
675    /// The MySQL database is implicit (`DATABASE()`), so there is no schema
676    /// qualifier to thread. `DATA_TYPE` / `IS_NULLABLE` round-trip through
677    /// `mysql_data_type_to_json_schema`.
678    async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
679        let columns = self.read_columns().await?;
680        if columns.is_empty() {
681            return Ok(None); // table does not exist yet
682        }
683
684        let mut props = serde_json::Map::new();
685        for (name, data_type, nullable) in columns {
686            props.insert(name, mysql_data_type_to_json_schema(&data_type, nullable));
687        }
688        Ok(Some(
689            serde_json::json!({ "type": "object", "properties": props }),
690        ))
691    }
692
693    /// Apply an additive schema evolution (new columns, lossless widenings,
694    /// nullability relaxations) to the destination table (issue #194).
695    ///
696    /// MySQL has no `ADD COLUMN IF NOT EXISTS` (pre-8.0.x), so the current
697    /// column set is read first and an `ADD COLUMN` is emitted only for names
698    /// not already present — making re-runs idempotent. Widenings use
699    /// `MODIFY COLUMN` (re-running the same MODIFY is a no-op); nullability
700    /// relaxations re-emit the column as its current mapped type with an
701    /// explicit `NULL`.
702    async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
703        let table_ref = quote_ident_mysql(&self.config.table_name);
704
705        // Read the current columns up front: needed for ADD-COLUMN idempotency
706        // (pre-check by name) and to derive a column's existing type when
707        // relaxing nullability.
708        let current = self.read_columns().await?;
709        let existing: std::collections::HashSet<&str> =
710            current.iter().map(|(n, _, _)| n.as_str()).collect();
711
712        let mut conn = self
713            .pool
714            .acquire()
715            .await
716            .map_err(|e| FaucetError::Sink(format!("MySQL evolve acquire failed: {e}")))?;
717
718        for c in &evolution.additions {
719            // Idempotency: MySQL lacks ADD COLUMN IF NOT EXISTS, so skip a
720            // column that already exists rather than erroring on a re-run.
721            if existing.contains(c.name.as_str()) {
722                continue;
723            }
724            let t = json_schema_base_type(&c.to).unwrap_or(SqlBaseType::Text);
725            sqlx::query(&build_add_column_sql(&table_ref, &c.name, t))
726                .execute(&mut *conn)
727                .await
728                .map_err(|e| {
729                    FaucetError::Sink(format!("MySQL ADD COLUMN {} failed: {e}", c.name))
730                })?;
731        }
732
733        for c in &evolution.widenings {
734            let t = json_schema_base_type(&c.to).unwrap_or(SqlBaseType::Text);
735            sqlx::query(&build_modify_column_sql(&table_ref, &c.name, t))
736                .execute(&mut *conn)
737                .await
738                .map_err(|e| {
739                    FaucetError::Sink(format!("MySQL MODIFY COLUMN {} failed: {e}", c.name))
740                })?;
741        }
742
743        for col in &evolution.relax_nullability {
744            // Re-emit the column as its CURRENT type but explicitly nullable.
745            // MySQL's MODIFY COLUMN requires the full type spec, so map the
746            // column's existing DATA_TYPE back to a base type and re-emit it.
747            let existing_type = current
748                .iter()
749                .find(|(n, _, _)| n == col)
750                .map(|(_, dt, nullable)| {
751                    let fragment = mysql_data_type_to_json_schema(dt, *nullable);
752                    json_schema_base_type(&fragment).unwrap_or(SqlBaseType::Text)
753                })
754                .unwrap_or(SqlBaseType::Text);
755            let sql = format!(
756                "ALTER TABLE {table_ref} MODIFY COLUMN {} {} NULL",
757                quote_ident_mysql(col),
758                mysql_keyword(existing_type)
759            );
760            sqlx::query(&sql)
761                .execute(&mut *conn)
762                .await
763                .map_err(|e| FaucetError::Sink(format!("MySQL DROP NOT NULL {col} failed: {e}")))?;
764        }
765
766        Ok(())
767    }
768
769    /// Write records to MySQL.
770    ///
771    /// When `config.batch_size > 0` and the input slice is larger than
772    /// `batch_size`, the slice is split into chunks of `batch_size` rows and
773    /// each chunk is sent as a separate multi-row `INSERT`. When
774    /// `config.batch_size == 0`, the entire slice is sent in a single
775    /// multi-row `INSERT` — useful when upstream `StreamPage`s are already
776    /// sized for MySQL's `max_allowed_packet` limit.
777    ///
778    /// Acquires a single connection from the pool and sends every chunk
779    /// through it under autocommit (no explicit transaction), preserving the
780    /// pre-refactor observable behaviour.
781    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
782        if records.is_empty() {
783            return Ok(0);
784        }
785
786        // Non-append modes: plan the writes and apply atomically.
787        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
788            let plan = faucet_core::plan_writes(records, &self.config.write);
789            if let Some((idx, msg)) = plan.failed.first() {
790                return Err(FaucetError::Sink(format!(
791                    "mysql {}: row {idx}: {msg}",
792                    self.config.write.write_mode.as_str()
793                )));
794            }
795            return self.apply_plan(&plan).await;
796        }
797
798        let mut conn = self
799            .pool
800            .acquire()
801            .await
802            .map_err(|e| FaucetError::Sink(format!("MySQL pool acquire failed: {e}")))?;
803
804        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
805            // Sentinel: pass the entire upstream page through in a single
806            // multi-row INSERT. Subject to MySQL's max_allowed_packet
807            // (default 64MB).
808            vec![records]
809        } else {
810            records.chunks(self.config.batch_size).collect()
811        };
812
813        let mut total = 0;
814        for chunk in chunks {
815            total += match &self.config.column_mapping {
816                MysqlColumnMapping::Json { column } => {
817                    self.insert_json(&mut conn, chunk, column).await?
818                }
819                MysqlColumnMapping::AutoMap => self.insert_auto_map(&mut conn, chunk).await?,
820            };
821        }
822
823        tracing::info!(
824            table = %self.config.table_name,
825            rows = total,
826            "MySQL write complete"
827        );
828        Ok(total)
829    }
830
831    /// Write a batch and report per-row outcomes.
832    ///
833    /// In append mode this delegates to [`write_batch`](faucet_core::Sink::write_batch) and
834    /// maps a single success onto an all-`Ok(())` vector (the trait default).
835    /// In upsert/delete mode the good rows are applied (upserts + deletes), and
836    /// only the rows whose key could not be extracted (missing / null key) are
837    /// reported as `Err` so the pipeline routes them to the DLQ per-row instead
838    /// of sending the whole page.
839    async fn write_batch_partial(
840        &self,
841        records: &[Value],
842    ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
843        if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
844            self.write_batch(records).await?;
845            return Ok(records.iter().map(|_| Ok(())).collect());
846        }
847
848        let plan = faucet_core::plan_writes(records, &self.config.write);
849        self.apply_plan(&plan).await?;
850
851        let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
852        for (idx, msg) in &plan.failed {
853            outcomes[*idx] = Err(FaucetError::Sink(format!(
854                "mysql {}: {msg}",
855                self.config.write.write_mode.as_str()
856            )));
857        }
858        Ok(outcomes)
859    }
860
861    fn supports_idempotent_writes(&self) -> bool {
862        true
863    }
864
865    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
866        self.ensure_commit_table().await?;
867        let sql = format!(
868            "SELECT {k} FROM {t} WHERE {s} = ?",
869            t = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
870            k = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
871            s = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
872        );
873        let row = sqlx::query(&sql)
874            .bind(scope)
875            .fetch_optional(&self.pool)
876            .await
877            .map_err(|e| FaucetError::Sink(format!("MySQL token read failed: {e}")))?;
878        Ok(row.map(|r| r.get::<String, _>(0)))
879    }
880
881    async fn write_batch_idempotent(
882        &self,
883        records: &[Value],
884        scope: &str,
885        token: &str,
886    ) -> Result<usize, FaucetError> {
887        self.ensure_commit_table().await?;
888
889        // For upsert/delete modes, plan the page before opening the transaction
890        // so a key-extraction failure aborts without leaving an open tx.
891        let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
892            None
893        } else {
894            let plan = faucet_core::plan_writes(records, &self.config.write);
895            if let Some((idx, msg)) = plan.failed.first() {
896                return Err(FaucetError::Sink(format!(
897                    "mysql {}: row {idx}: {msg}",
898                    self.config.write.write_mode.as_str()
899                )));
900            }
901            Some(plan)
902        };
903
904        let mut tx = self
905            .pool
906            .begin()
907            .await
908            .map_err(|e| FaucetError::Sink(format!("MySQL transaction begin failed: {e}")))?;
909
910        // Data write and the commit-token upsert share ONE transaction so the
911        // page is committed atomically with its watermark. For upsert/delete the
912        // planned upserts/deletes commit together with the watermark in this same
913        // tx (no nested tx — the helpers run on this transaction's connection).
914        let written = match &plan {
915            Some(plan) => {
916                let mut affected = 0usize;
917                if !plan.upserts.is_empty() {
918                    affected += self
919                        .insert_auto_map_with_conflict(
920                            &mut tx,
921                            &plan.upserts,
922                            Some(&self.config.write.key),
923                        )
924                        .await?;
925                }
926                if !plan.deletes.is_empty() {
927                    affected += self.delete_by_keys(&mut tx, &plan.deletes).await?;
928                }
929                affected
930            }
931            None => match &self.config.column_mapping {
932                MysqlColumnMapping::Json { column } => {
933                    self.insert_json(&mut tx, records, column).await?
934                }
935                MysqlColumnMapping::AutoMap => self.insert_auto_map(&mut tx, records).await?,
936            },
937        };
938
939        let upsert = format!(
940            "INSERT INTO {t} ({s}, {k}) VALUES (?, ?) ON DUPLICATE KEY UPDATE {k} = VALUES({k})",
941            t = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
942            s = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
943            k = quote_ident_mysql(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
944        );
945        sqlx::query(&upsert)
946            .bind(scope)
947            .bind(token)
948            .execute(&mut *tx)
949            .await
950            .map_err(|e| FaucetError::Sink(format!("MySQL token upsert failed: {e}")))?;
951
952        tx.commit()
953            .await
954            .map_err(|e| FaucetError::Sink(format!("MySQL transaction commit failed: {e}")))?;
955
956        Ok(written)
957    }
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963
964    // dataset_uri test is skipped: MysqlSink::new() requires a live pool
965    // (connects to MySQL in new()), and no offline constructor exists.
966
967    #[test]
968    fn commit_token_table_is_the_shared_constant() {
969        assert_eq!(
970            faucet_core::idempotency::COMMIT_TOKEN_TABLE,
971            "_faucet_commit_token"
972        );
973    }
974
975    #[test]
976    fn quote_ident_mysql_simple() {
977        assert_eq!(quote_ident_mysql("my_table"), "`my_table`");
978    }
979
980    #[test]
981    fn quote_ident_mysql_with_backtick() {
982        assert_eq!(quote_ident_mysql("has`tick"), "`has``tick`");
983    }
984
985    #[test]
986    fn quote_ident_mysql_empty() {
987        assert_eq!(quote_ident_mysql(""), "``");
988    }
989
990    #[test]
991    fn quote_ident_mysql_special_chars() {
992        assert_eq!(quote_ident_mysql("table; DROP"), "`table; DROP`");
993    }
994
995    #[test]
996    fn mysql_on_duplicate_clause() {
997        let clause =
998            on_duplicate_clause(&["id".to_string()], &["id".to_string(), "name".to_string()]);
999        assert_eq!(clause, "ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)");
1000    }
1001
1002    #[test]
1003    fn mysql_on_duplicate_all_keys_self_assign() {
1004        let clause = on_duplicate_clause(&["id".to_string()], &["id".to_string()]);
1005        assert_eq!(clause, "ON DUPLICATE KEY UPDATE `id` = `id`");
1006    }
1007
1008    #[test]
1009    fn mysql_on_duplicate_composite_key_partial_update() {
1010        let clause = on_duplicate_clause(
1011            &["a".to_string(), "b".to_string()],
1012            &["a".to_string(), "b".to_string(), "v".to_string()],
1013        );
1014        assert_eq!(clause, "ON DUPLICATE KEY UPDATE `v` = VALUES(`v`)");
1015    }
1016
1017    #[test]
1018    fn mysql_add_column_ddl() {
1019        let sql = build_add_column_sql("`t`", "email", SqlBaseType::Text);
1020        assert_eq!(sql, "ALTER TABLE `t` ADD COLUMN `email` LONGTEXT");
1021
1022        // A backtick in the column name is doubled (SQL-injection safety).
1023        let sql = build_add_column_sql("`t`", "ev`il", SqlBaseType::Integer);
1024        assert_eq!(sql, "ALTER TABLE `t` ADD COLUMN `ev``il` BIGINT");
1025    }
1026
1027    #[test]
1028    fn mysql_modify_column_ddl() {
1029        let sql = build_modify_column_sql("`t`", "score", SqlBaseType::Double);
1030        assert_eq!(sql, "ALTER TABLE `t` MODIFY COLUMN `score` DOUBLE");
1031
1032        let sql = build_modify_column_sql("`t`", "flag", SqlBaseType::Boolean);
1033        assert_eq!(sql, "ALTER TABLE `t` MODIFY COLUMN `flag` TINYINT(1)");
1034    }
1035
1036    #[test]
1037    fn mysql_keyword_mapping() {
1038        assert_eq!(mysql_keyword(SqlBaseType::Integer), "BIGINT");
1039        assert_eq!(mysql_keyword(SqlBaseType::Double), "DOUBLE");
1040        assert_eq!(mysql_keyword(SqlBaseType::Boolean), "TINYINT(1)");
1041        assert_eq!(mysql_keyword(SqlBaseType::Text), "LONGTEXT");
1042        assert_eq!(mysql_keyword(SqlBaseType::Json), "JSON");
1043    }
1044
1045    fn idx(cols: &[&str]) -> std::collections::BTreeSet<String> {
1046        cols.iter().map(|s| s.to_string()).collect()
1047    }
1048
1049    fn keyvec(cols: &[&str]) -> Vec<String> {
1050        cols.iter().map(|s| s.to_string()).collect()
1051    }
1052
1053    #[test]
1054    fn key_matches_single_column_index() {
1055        let indexes = vec![idx(&["id"])];
1056        assert!(key_matches_unique_index(&indexes, &keyvec(&["id"])));
1057    }
1058
1059    #[test]
1060    fn key_matches_composite_index_reordered() {
1061        // A UNIQUE index on (a, b) matches a configured key of [b, a].
1062        let indexes = vec![idx(&["a", "b"])];
1063        assert!(key_matches_unique_index(&indexes, &keyvec(&["b", "a"])));
1064    }
1065
1066    #[test]
1067    fn key_does_not_match_subset_of_index() {
1068        // Index is (a, b); key [a] is a prefix/subset — not an exact match.
1069        let indexes = vec![idx(&["a", "b"])];
1070        assert!(!key_matches_unique_index(&indexes, &keyvec(&["a"])));
1071    }
1072
1073    #[test]
1074    fn key_does_not_match_superset_of_index() {
1075        // Index is (a); key [a, b] is a superset — not an exact match.
1076        let indexes = vec![idx(&["a"])];
1077        assert!(!key_matches_unique_index(&indexes, &keyvec(&["a", "b"])));
1078    }
1079
1080    #[test]
1081    fn key_does_not_match_disjoint_index() {
1082        let indexes = vec![idx(&["id"])];
1083        assert!(!key_matches_unique_index(&indexes, &keyvec(&["other"])));
1084    }
1085
1086    #[test]
1087    fn key_matches_one_of_multiple_indexes() {
1088        // Table has a PRIMARY (id) and a UNIQUE (email); key on email matches.
1089        let indexes = vec![idx(&["id"]), idx(&["email"])];
1090        assert!(key_matches_unique_index(&indexes, &keyvec(&["email"])));
1091        assert!(key_matches_unique_index(&indexes, &keyvec(&["id"])));
1092        // A key on neither matches.
1093        assert!(!key_matches_unique_index(&indexes, &keyvec(&["name"])));
1094        // A key spanning two distinct indexes is not itself an index.
1095        assert!(!key_matches_unique_index(
1096            &indexes,
1097            &keyvec(&["id", "email"])
1098        ));
1099    }
1100
1101    #[test]
1102    fn key_does_not_match_empty_index_set() {
1103        // No unique indexes (e.g. table absent / no constraints) → never matches.
1104        let indexes: Vec<std::collections::BTreeSet<String>> = vec![];
1105        assert!(!key_matches_unique_index(&indexes, &keyvec(&["id"])));
1106    }
1107
1108    #[test]
1109    fn empty_key_never_matches() {
1110        let indexes = vec![idx(&["id"])];
1111        assert!(!key_matches_unique_index(&indexes, &keyvec(&[])));
1112        // Even against an (impossible) empty index, an empty key is rejected.
1113        let empty_idx = vec![idx(&[])];
1114        assert!(!key_matches_unique_index(&empty_idx, &keyvec(&[])));
1115    }
1116
1117    #[test]
1118    fn key_matches_composite_index_among_several() {
1119        let indexes = vec![idx(&["id"]), idx(&["tenant", "slug"])];
1120        assert!(key_matches_unique_index(
1121            &indexes,
1122            &keyvec(&["slug", "tenant"])
1123        ));
1124        assert!(!key_matches_unique_index(&indexes, &keyvec(&["tenant"])));
1125    }
1126
1127    #[test]
1128    fn mysql_data_type_round_trips_to_json_schema() {
1129        use serde_json::json;
1130        assert_eq!(
1131            mysql_data_type_to_json_schema("bigint", false),
1132            json!({"type":"integer"})
1133        );
1134        assert_eq!(
1135            mysql_data_type_to_json_schema("int", false),
1136            json!({"type":"integer"})
1137        );
1138        // tinyint maps to integer (precision is not exposed by DATA_TYPE, so we
1139        // never guess boolean for safety).
1140        assert_eq!(
1141            mysql_data_type_to_json_schema("tinyint", false),
1142            json!({"type":"integer"})
1143        );
1144        assert_eq!(
1145            mysql_data_type_to_json_schema("double", false),
1146            json!({"type":"number"})
1147        );
1148        assert_eq!(
1149            mysql_data_type_to_json_schema("decimal", false),
1150            json!({"type":"number"})
1151        );
1152        assert_eq!(
1153            mysql_data_type_to_json_schema("json", false),
1154            json!({"type":"object"})
1155        );
1156        assert_eq!(
1157            mysql_data_type_to_json_schema("varchar", false),
1158            json!({"type":"string"})
1159        );
1160        // Unknown types fall back to string; nullable widens the type array.
1161        assert_eq!(
1162            mysql_data_type_to_json_schema("datetime", true),
1163            json!({"type":["string","null"]})
1164        );
1165    }
1166}