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