1use 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
10pub struct MysqlSink {
12 config: MysqlSinkConfig,
13 pool: MySqlPool,
14}
15
16fn quote_ident_mysql(name: &str) -> String {
21 format!("`{}`", name.replace('`', "``"))
22}
23
24fn 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
39fn 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
53fn 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
64fn 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
86fn 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 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 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 async fn read_unique_indexes(
184 &self,
185 ) -> Result<Vec<std::collections::BTreeSet<String>>, FaucetError> {
186 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 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 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 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 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 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 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 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 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 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 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 q.bind(n.to_string())
428 }
429 }
430 Some(Value::String(s)) => q.bind(s.clone()),
431 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 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 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 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 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 async fn read_columns(&self) -> Result<Vec<(String, String, bool)>, FaucetError> {
557 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 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 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 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 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); }
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 async fn evolve_schema(&self, evolution: &SchemaEvolution) -> Result<(), FaucetError> {
703 let table_ref = quote_ident_mysql(&self.config.table_name);
704
705 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 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 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 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
782 if records.is_empty() {
783 return Ok(0);
784 }
785
786 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 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 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 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 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 #[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 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 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 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 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 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 assert!(!key_matches_unique_index(&indexes, &keyvec(&["name"])));
1094 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 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 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 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 assert_eq!(
1162 mysql_data_type_to_json_schema("datetime", true),
1163 json!({"type":["string","null"]})
1164 );
1165 }
1166}