1use std::collections::HashMap;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Arc;
6use std::sync::Mutex as StdMutex;
7
8use async_trait::async_trait;
9use http::{Method, StatusCode};
10use serde_json::{json, Map, Value};
11use tokio::sync::Mutex;
12use tokio::time::{Duration, Instant};
13
14use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
15use fakecloud_rds::SharedRdsState;
16
17const SUPPORTED_ACTIONS: &[&str] = &[
18 "ExecuteStatement",
19 "BatchExecuteStatement",
20 "BeginTransaction",
21 "CommitTransaction",
22 "RollbackTransaction",
23 "ExecuteSql",
24];
25
26const TXN_IDLE_TIMEOUT: Duration = Duration::from_secs(180);
32
33struct DbConn {
36 engine: String,
37 host: String,
38 port: u16,
39 user: String,
40 password: String,
41 db_name: String,
42 schema: Option<String>,
45}
46
47enum HeldConn {
51 Pg(tokio_postgres::Client),
52 MySql(mysql_async::Conn),
53}
54
55struct TxnEntry {
60 conn: Mutex<Option<HeldConn>>,
62 last_used: StdMutex<Instant>,
63 in_flight: AtomicU64,
69}
70
71impl TxnEntry {
72 fn touch(&self) {
73 if let Ok(mut t) = self.last_used.lock() {
74 *t = Instant::now();
75 }
76 }
77
78 fn is_stale(&self, now: Instant) -> bool {
82 self.last_used
83 .lock()
84 .map(|t| now.duration_since(*t) > TXN_IDLE_TIMEOUT)
85 .unwrap_or(false)
86 }
87
88 fn is_reapable(&self, now: Instant) -> bool {
93 self.in_flight.load(Ordering::SeqCst) == 0 && self.is_stale(now)
94 }
95}
96
97struct InFlightGuard {
102 entry: Arc<TxnEntry>,
103}
104
105impl InFlightGuard {
106 fn pin(entry: Arc<TxnEntry>) -> Self {
107 entry.in_flight.fetch_add(1, Ordering::SeqCst);
108 entry.touch();
109 Self { entry }
110 }
111}
112
113impl Drop for InFlightGuard {
114 fn drop(&mut self) {
115 self.entry.touch();
119 self.entry.in_flight.fetch_sub(1, Ordering::SeqCst);
120 }
121}
122
123type TxnMap = Arc<Mutex<HashMap<String, Arc<TxnEntry>>>>;
124
125pub struct RdsDataService {
126 rds_state: SharedRdsState,
127 transactions: TxnMap,
130}
131
132impl RdsDataService {
133 pub fn new(rds_state: SharedRdsState) -> Self {
134 let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
135 if let Ok(handle) = tokio::runtime::Handle::try_current() {
139 handle.spawn(reap_idle_transactions(transactions.clone()));
140 }
141 Self {
142 rds_state,
143 transactions,
144 }
145 }
146
147 fn resolve_action(req: &AwsRequest) -> Option<&'static str> {
149 if req.method != Method::POST {
150 return None;
151 }
152 match req.path_segments.first().map(String::as_str)? {
153 "Execute" => Some("ExecuteStatement"),
154 "BatchExecute" => Some("BatchExecuteStatement"),
155 "BeginTransaction" => Some("BeginTransaction"),
156 "CommitTransaction" => Some("CommitTransaction"),
157 "RollbackTransaction" => Some("RollbackTransaction"),
158 "ExecuteSql" => Some("ExecuteSql"),
159 _ => None,
160 }
161 }
162
163 fn resolve_conn(&self, account_id: &str, resource_arn: &str) -> Option<DbConn> {
167 let cluster_id = resource_arn
168 .rsplit_once(":cluster:")
169 .map(|(_, id)| id.to_string());
170 let accounts = self.rds_state.read();
171 let state = accounts.get(account_id)?;
172 let inst = state.instances.values().find(|i| {
173 i.db_instance_arn == resource_arn
174 || cluster_id
175 .as_deref()
176 .is_some_and(|c| i.db_cluster_identifier.as_deref() == Some(c))
177 })?;
178 Some(DbConn {
179 engine: inst.engine.clone(),
180 host: inst.endpoint_address.clone(),
185 port: inst.host_port,
186 user: inst.master_username.clone(),
187 password: inst.master_user_password.clone(),
188 db_name: inst.db_name.clone().unwrap_or_default(),
189 schema: None,
190 })
191 }
192
193 fn require_conn(&self, req: &AwsRequest, body: &Value) -> Result<DbConn, AwsServiceError> {
198 let resource_arn = body
199 .get("resourceArn")
200 .and_then(Value::as_str)
201 .ok_or_else(|| bad_request("resourceArn is required"))?;
202 if body.get("secretArn").and_then(Value::as_str).is_none() {
205 return Err(bad_request("secretArn is required"));
206 }
207 let mut conn = self
208 .resolve_conn(&req.account_id, resource_arn)
209 .ok_or_else(|| {
210 bad_request(format!(
211 "HttpEndpoint is not enabled for resource {resource_arn} (no such DB)"
212 ))
213 })?;
214 if let Some(db) = body.get("database").and_then(Value::as_str) {
215 if !db.is_empty() {
216 conn.db_name = db.to_string();
217 }
218 }
219 conn.schema = body
220 .get("schema")
221 .and_then(Value::as_str)
222 .filter(|s| !s.is_empty())
223 .map(str::to_string);
224 Ok(conn)
225 }
226
227 async fn execute_statement(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
228 let body = req.json_body();
229 let sql = body
230 .get("sql")
231 .and_then(Value::as_str)
232 .ok_or_else(|| bad_request("sql is required"))?;
233 let include_metadata = body
234 .get("includeResultMetadata")
235 .and_then(Value::as_bool)
236 .unwrap_or(false);
237 let format_json = body
238 .get("formatRecordsAs")
239 .and_then(Value::as_str)
240 .map(|f| f == "JSON")
241 .unwrap_or(false);
242 let params = parse_parameters(body.get("parameters"));
243
244 if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
248 self.require_conn(req, &body)?;
251 let pin = self.acquire_txn(txid).await?;
252 let mut guard = pin.entry.conn.lock().await;
256 let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
257 let value = match held {
258 HeldConn::Pg(client) => {
259 pg_run(client, sql, ¶ms, include_metadata, format_json).await?
260 }
261 HeldConn::MySql(conn) => {
262 my_run(conn, sql, ¶ms, include_metadata, format_json).await?
263 }
264 };
265 return Ok(AwsResponse::ok_json(value));
266 }
267
268 let conn = self.require_conn(req, &body)?;
269 let engine = conn.engine.to_lowercase();
270 let value = if engine.contains("postgres") {
271 let client = pg_connect(&conn).await?;
272 pg_run(&client, sql, ¶ms, include_metadata, format_json).await?
273 } else if is_mysql(&engine) {
274 let mut c = my_connect(&conn).await?;
275 let v = my_run(&mut c, sql, ¶ms, include_metadata, format_json).await;
276 let _ = c.disconnect().await;
277 v?
278 } else {
279 return Err(unsupported_engine(&conn.engine));
280 };
281 Ok(AwsResponse::ok_json(value))
282 }
283
284 async fn begin_transaction(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
285 let body = req.json_body();
286 let conn = self.require_conn(req, &body)?;
287 let engine = conn.engine.to_lowercase();
288
289 let held = if engine.contains("postgres") {
290 let client = pg_connect(&conn).await?;
291 client
292 .batch_execute("BEGIN")
293 .await
294 .map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
295 HeldConn::Pg(client)
296 } else if is_mysql(&engine) {
297 use mysql_async::prelude::Queryable;
298 let mut c = my_connect(&conn).await?;
299 c.query_drop("START TRANSACTION")
300 .await
301 .map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
302 HeldConn::MySql(c)
303 } else {
304 return Err(unsupported_engine(&conn.engine));
305 };
306
307 let txid = gen_transaction_id();
308 let entry = Arc::new(TxnEntry {
309 conn: Mutex::new(Some(held)),
310 last_used: StdMutex::new(Instant::now()),
311 in_flight: AtomicU64::new(0),
312 });
313 self.transactions.lock().await.insert(txid.clone(), entry);
314 Ok(AwsResponse::ok_json(json!({ "transactionId": txid })))
315 }
316
317 async fn acquire_txn(&self, txid: &str) -> Result<InFlightGuard, AwsServiceError> {
324 let map = self.transactions.lock().await;
325 let entry = map.get(txid).cloned().ok_or_else(|| not_found_txn(txid))?;
326 Ok(InFlightGuard::pin(entry))
327 }
328
329 async fn finish_transaction(
332 &self,
333 req: &AwsRequest,
334 sql_keyword: &str,
335 status: &str,
336 ) -> Result<AwsResponse, AwsServiceError> {
337 let body = req.json_body();
338 let txid = body
339 .get("transactionId")
340 .and_then(Value::as_str)
341 .ok_or_else(|| bad_request("transactionId is required"))?;
342 let entry = self
343 .transactions
344 .lock()
345 .await
346 .remove(txid)
347 .ok_or_else(|| not_found_txn(txid))?;
348 let held = entry
351 .conn
352 .lock()
353 .await
354 .take()
355 .ok_or_else(|| not_found_txn(txid))?;
356 finish_held(held, sql_keyword).await?;
357 Ok(AwsResponse::ok_json(json!({ "transactionStatus": status })))
358 }
359
360 async fn batch_execute_statement(
361 &self,
362 req: &AwsRequest,
363 ) -> Result<AwsResponse, AwsServiceError> {
364 let body = req.json_body();
365 let sql = body
366 .get("sql")
367 .and_then(Value::as_str)
368 .ok_or_else(|| bad_request("sql is required"))?;
369 let sets = parse_parameter_sets(body.get("parameterSets"));
370
371 if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
374 self.require_conn(req, &body)?;
375 let pin = self.acquire_txn(txid).await?;
376 let mut guard = pin.entry.conn.lock().await;
377 let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
378 let mut results = Vec::with_capacity(sets.len().max(1));
379 match held {
380 HeldConn::Pg(client) => {
381 for set in run_each(&sets) {
382 let v = pg_run(client, sql, set, false, false).await?;
383 results.push(batch_update_result(&v));
384 }
385 }
386 HeldConn::MySql(conn) => {
387 for set in run_each(&sets) {
388 let v = my_run(conn, sql, set, false, false).await?;
389 results.push(batch_update_result(&v));
390 }
391 }
392 }
393 return Ok(AwsResponse::ok_json(json!({ "updateResults": results })));
394 }
395
396 let conn = self.require_conn(req, &body)?;
397 let engine = conn.engine.to_lowercase();
398 let mut results = Vec::with_capacity(sets.len().max(1));
399 if engine.contains("postgres") {
400 let client = pg_connect(&conn).await?;
401 for set in run_each(&sets) {
402 let v = pg_run(&client, sql, set, false, false).await?;
403 results.push(batch_update_result(&v));
404 }
405 } else if is_mysql(&engine) {
406 let mut c = my_connect(&conn).await?;
407 for set in run_each(&sets) {
408 match my_run(&mut c, sql, set, false, false).await {
409 Ok(v) => results.push(batch_update_result(&v)),
410 Err(e) => {
411 let _ = c.disconnect().await;
412 return Err(e);
413 }
414 }
415 }
416 let _ = c.disconnect().await;
417 } else {
418 return Err(unsupported_engine(&conn.engine));
419 }
420 Ok(AwsResponse::ok_json(json!({ "updateResults": results })))
421 }
422}
423
424#[async_trait]
425impl AwsService for RdsDataService {
426 fn service_name(&self) -> &str {
427 "rds-data"
428 }
429
430 fn supported_actions(&self) -> &[&str] {
431 SUPPORTED_ACTIONS
432 }
433
434 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
435 let Some(action) = Self::resolve_action(&req) else {
436 return Err(AwsServiceError::aws_error(
437 StatusCode::NOT_FOUND,
438 "ResourceNotFoundException",
439 format!("Unknown operation: {} {}", req.method, req.raw_path),
440 ));
441 };
442 match action {
443 "ExecuteStatement" => self.execute_statement(&req).await,
444 "BatchExecuteStatement" => self.batch_execute_statement(&req).await,
445 "BeginTransaction" => self.begin_transaction(&req).await,
446 "CommitTransaction" => {
447 self.finish_transaction(&req, "COMMIT", "Transaction Committed")
448 .await
449 }
450 "RollbackTransaction" => {
451 self.finish_transaction(&req, "ROLLBACK", "Rollback Complete")
452 .await
453 }
454 other => Err(AwsServiceError::aws_error(
458 StatusCode::BAD_REQUEST,
459 "BadRequestException",
460 format!("rds-data operation {other} is not supported"),
461 )),
462 }
463 }
464}
465
466fn bad_request(msg: impl Into<String>) -> AwsServiceError {
467 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg.into())
468}
469
470fn not_found_txn(txid: &str) -> AwsServiceError {
471 AwsServiceError::aws_error(
472 StatusCode::BAD_REQUEST,
473 "BadRequestException",
474 format!("Transaction {txid} is not found"),
475 )
476}
477
478fn unsupported_engine(engine: &str) -> AwsServiceError {
479 bad_request(format!("RDS Data API is not supported for engine {engine}"))
480}
481
482fn is_mysql(engine_lower: &str) -> bool {
483 engine_lower.contains("mysql") || engine_lower.contains("maria")
484}
485
486fn gen_transaction_id() -> String {
488 use rand::RngCore;
489 let mut bytes = [0u8; 48];
490 rand::thread_rng().fill_bytes(&mut bytes);
491 b64_encode(&bytes)
492}
493
494async fn finish_held(held: HeldConn, sql_keyword: &str) -> Result<(), AwsServiceError> {
497 match held {
498 HeldConn::Pg(client) => {
499 client
500 .batch_execute(sql_keyword)
501 .await
502 .map_err(|e| bad_request(format!("{e}")))?;
503 }
504 HeldConn::MySql(mut conn) => {
505 use mysql_async::prelude::Queryable;
506 conn.query_drop(sql_keyword)
507 .await
508 .map_err(|e| bad_request(format!("{e}")))?;
509 let _ = conn.disconnect().await;
510 }
511 }
512 Ok(())
513}
514
515async fn reap_idle_transactions(transactions: TxnMap) {
519 let mut tick = tokio::time::interval(Duration::from_secs(30));
520 loop {
521 tick.tick().await;
522 reap_once(&transactions).await;
523 }
524}
525
526async fn collect_stale(transactions: &TxnMap, now: Instant) -> Vec<(String, Arc<TxnEntry>)> {
529 let map = transactions.lock().await;
530 map.iter()
531 .filter(|(_, e)| e.is_reapable(now))
532 .map(|(id, e)| (id.clone(), e.clone()))
533 .collect()
534}
535
536async fn reap_stale(transactions: &TxnMap, stale: Vec<(String, Arc<TxnEntry>)>) {
544 for (id, entry) in stale {
545 let mut map = transactions.lock().await;
546 let still_reapable = entry.is_reapable(Instant::now())
547 && map.get(&id).is_some_and(|e| Arc::ptr_eq(e, &entry));
548 if !still_reapable {
549 continue;
550 }
551 map.remove(&id);
552 drop(map);
553 if let Some(held) = entry.conn.lock().await.take() {
554 let _ = finish_held(held, "ROLLBACK").await;
555 }
556 }
557}
558
559async fn reap_once(transactions: &TxnMap) {
561 let stale = collect_stale(transactions, Instant::now()).await;
562 reap_stale(transactions, stale).await;
563}
564
565enum SqlValue {
567 Null,
568 Bool(bool),
569 Long(i64),
570 Double(f64),
571 Text(String),
572 Blob(Vec<u8>),
573}
574
575type Params = Vec<(String, SqlValue)>;
576
577fn parse_parameters(params: Option<&Value>) -> Params {
579 let Some(arr) = params.and_then(Value::as_array) else {
580 return Vec::new();
581 };
582 arr.iter()
583 .filter_map(|p| {
584 let name = p.get("name").and_then(Value::as_str)?.to_string();
585 let v = p.get("value")?;
586 let sv = if v.get("isNull").and_then(Value::as_bool) == Some(true) {
587 SqlValue::Null
588 } else if let Some(b) = v.get("booleanValue").and_then(Value::as_bool) {
589 SqlValue::Bool(b)
590 } else if let Some(n) = v.get("longValue").and_then(Value::as_i64) {
591 SqlValue::Long(n)
592 } else if let Some(d) = v.get("doubleValue").and_then(Value::as_f64) {
593 SqlValue::Double(d)
594 } else if let Some(s) = v.get("stringValue").and_then(Value::as_str) {
595 SqlValue::Text(s.to_string())
596 } else if let Some(b) = v.get("blobValue").and_then(Value::as_str) {
597 SqlValue::Blob(b64_decode(b))
599 } else {
600 SqlValue::Null
601 };
602 Some((name, sv))
603 })
604 .collect()
605}
606
607fn parse_parameter_sets(sets: Option<&Value>) -> Vec<Params> {
609 let Some(arr) = sets.and_then(Value::as_array) else {
610 return Vec::new();
611 };
612 arr.iter().map(|s| parse_parameters(Some(s))).collect()
613}
614
615fn run_each(sets: &[Params]) -> Vec<&[(String, SqlValue)]> {
618 if sets.is_empty() {
619 vec![&[]]
620 } else {
621 sets.iter().map(Vec::as_slice).collect()
622 }
623}
624
625fn inline_params(
632 sql: &str,
633 params: &[(String, SqlValue)],
634 lit: impl Fn(&SqlValue) -> String,
635) -> String {
636 let mut out = String::with_capacity(sql.len());
637 let bytes = sql.as_bytes();
638 let mut i = 0;
639 while i < bytes.len() {
640 if bytes[i] == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
641 let mut j = i + 1;
642 while j < bytes.len() && is_ident_char(bytes[j]) {
643 j += 1;
644 }
645 let name = &sql[i + 1..j];
646 if let Some((_, v)) = params.iter().find(|(n, _)| n == name) {
647 out.push_str(&lit(v));
648 i = j;
649 continue;
650 }
651 }
652 let ch = sql[i..]
657 .chars()
658 .next()
659 .expect("byte index is on a char boundary");
660 out.push(ch);
661 i += ch.len_utf8();
662 }
663 out
664}
665
666fn sql_quote(s: &str) -> String {
667 format!("'{}'", s.replace('\'', "''"))
668}
669
670fn quote_ident(s: &str) -> String {
673 format!("\"{}\"", s.replace('"', "\"\""))
674}
675
676fn pg_literal(v: &SqlValue) -> String {
678 match v {
679 SqlValue::Null => "NULL".to_string(),
680 SqlValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
681 SqlValue::Long(n) => n.to_string(),
682 SqlValue::Double(d) => d.to_string(),
683 SqlValue::Text(s) => sql_quote(s),
684 SqlValue::Blob(b) => format!("'\\x{}'::bytea", hex(b)),
685 }
686}
687
688fn mysql_literal(v: &SqlValue) -> String {
690 match v {
691 SqlValue::Null => "NULL".to_string(),
692 SqlValue::Bool(b) => if *b { "1" } else { "0" }.to_string(),
693 SqlValue::Long(n) => n.to_string(),
694 SqlValue::Double(d) => d.to_string(),
695 SqlValue::Text(s) => sql_quote(s),
696 SqlValue::Blob(b) => format!("x'{}'", hex(b)),
697 }
698}
699
700fn is_returning_dml(sql: &str) -> bool {
705 let head = sql.trim_start().to_ascii_uppercase();
706 (head.starts_with("INSERT") || head.starts_with("UPDATE") || head.starts_with("DELETE"))
707 && head.contains(" RETURNING ")
708}
709
710fn batch_update_result(run_output: &Value) -> Value {
714 let generated = run_output
715 .get("generatedFields")
716 .cloned()
717 .unwrap_or_else(|| json!([]));
718 json!({ "generatedFields": generated })
719}
720
721fn pg_column_metadata(cols: &[tokio_postgres::Column]) -> Value {
723 Value::Array(
724 cols.iter()
725 .map(|c| {
726 json!({
727 "name": c.name(),
728 "label": c.name(),
729 "typeName": c.type_().name(),
730 "nullable": 2, })
732 })
733 .collect(),
734 )
735}
736
737fn my_column_metadata(cols: &[mysql_async::Column]) -> Value {
739 Value::Array(
740 cols.iter()
741 .map(|c| {
742 json!({
743 "name": c.name_str().to_string(),
744 "label": c.name_str().to_string(),
745 "typeName": format!("{:?}", c.column_type()),
746 "nullable": 2,
747 })
748 })
749 .collect(),
750 )
751}
752
753fn returns_rows(sql: &str) -> bool {
755 let head = sql.trim_start().to_ascii_uppercase();
756 head.starts_with("SELECT")
757 || head.starts_with("WITH")
758 || head.starts_with("SHOW")
759 || head.starts_with("VALUES")
760 || head.starts_with("TABLE")
761 || head.starts_with("EXPLAIN")
762 || head.contains(" RETURNING ")
763}
764
765fn hex(b: &[u8]) -> String {
766 let mut s = String::with_capacity(b.len() * 2);
767 for byte in b {
768 s.push_str(&format!("{byte:02x}"));
769 }
770 s
771}
772
773fn is_ident_start(b: u8) -> bool {
774 b.is_ascii_alphabetic() || b == b'_'
775}
776fn is_ident_char(b: u8) -> bool {
777 b.is_ascii_alphanumeric() || b == b'_'
778}
779
780async fn pg_connect(conn: &DbConn) -> Result<tokio_postgres::Client, AwsServiceError> {
781 use tokio_postgres::NoTls;
782 let cs = format!(
783 "host={} port={} user={} password={} dbname={}",
784 conn.host, conn.port, conn.user, conn.password, conn.db_name
785 );
786 let (client, connection) = tokio_postgres::connect(&cs, NoTls)
787 .await
788 .map_err(|e| bad_request(format!("could not connect to database: {e}")))?;
789 tokio::spawn(async move {
790 let _ = connection.await;
791 });
792 if let Some(schema) = &conn.schema {
793 client
794 .batch_execute(&format!("SET search_path TO {}", quote_ident(schema)))
795 .await
796 .map_err(|e| bad_request(format!("could not set schema: {e}")))?;
797 }
798 Ok(client)
799}
800
801async fn pg_run(
802 client: &tokio_postgres::Client,
803 sql: &str,
804 params: &[(String, SqlValue)],
805 include_metadata: bool,
806 format_json: bool,
807) -> Result<Value, AwsServiceError> {
808 let stmt = inline_params(sql, params, pg_literal);
809 let no_params: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[];
810
811 let mut out = Map::new();
812 if !returns_rows(&stmt) {
815 let affected = client
816 .execute(stmt.as_str(), no_params)
817 .await
818 .map_err(|e| bad_request(format!("{e}")))?;
819 out.insert("numberOfRecordsUpdated".into(), json!(affected));
820 out.insert("records".into(), json!([]));
821 return Ok(Value::Object(out));
822 }
823
824 let statement = client
830 .prepare(stmt.as_str())
831 .await
832 .map_err(|e| bad_request(format!("{e}")))?;
833 let rows = client
834 .query(&statement, no_params)
835 .await
836 .map_err(|e| bad_request(format!("{e}")))?;
837 let cols = statement.columns();
838
839 let updated = if is_returning_dml(&stmt) {
844 rows.len() as i64
845 } else {
846 0
847 };
848 out.insert("numberOfRecordsUpdated".into(), json!(updated));
849
850 if include_metadata {
851 out.insert("columnMetadata".into(), pg_column_metadata(cols));
852 }
853
854 if rows.is_empty() {
855 if format_json {
856 out.insert("formattedRecords".into(), json!("[]"));
857 } else {
858 out.insert("records".into(), json!([]));
859 }
860 return Ok(Value::Object(out));
861 }
862
863 let mut records: Vec<Value> = Vec::with_capacity(rows.len());
864 let mut json_rows: Vec<Map<String, Value>> = Vec::new();
865 for row in &rows {
866 let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
867 let mut jr = Map::new();
868 for (i, col) in cols.iter().enumerate() {
869 let field = pg_field(row, i, col.type_());
870 if format_json {
871 jr.insert(col.name().to_string(), field_to_plain(&field));
872 }
873 rec.push(field);
874 }
875 records.push(Value::Array(rec));
876 if format_json {
877 json_rows.push(jr);
878 }
879 }
880 if format_json {
881 out.insert(
882 "formattedRecords".into(),
883 json!(serde_json::to_string(&json_rows).unwrap_or_default()),
884 );
885 } else {
886 out.insert("records".into(), Value::Array(records));
887 }
888 Ok(Value::Object(out))
889}
890
891fn pg_timestamp_text(base: impl std::fmt::Display, micros: u32) -> String {
894 if micros == 0 {
895 return base.to_string();
896 }
897 let frac = format!("{micros:06}");
898 let frac = frac.trim_end_matches('0');
899 format!("{base}.{frac}")
900}
901
902fn pg_field(row: &tokio_postgres::Row, i: usize, ty: &tokio_postgres::types::Type) -> Value {
903 use tokio_postgres::types::Type;
904 macro_rules! opt {
905 ($t:ty, $key:expr, $conv:expr) => {{
906 let v: Option<$t> = row.try_get(i).unwrap_or(None);
907 match v {
908 Some(x) => json!({ $key: $conv(x) }),
909 None => json!({ "isNull": true }),
910 }
911 }};
912 }
913 macro_rules! strv {
919 ($t:ty, $conv:expr) => {{
920 let v: Option<$t> = row.try_get(i).unwrap_or(None);
921 match v {
922 Some(x) => json!({ "stringValue": $conv(x) }),
923 None => json!({ "isNull": true }),
924 }
925 }};
926 }
927 match *ty {
928 Type::BOOL => opt!(bool, "booleanValue", |x| x),
929 Type::INT2 => opt!(i16, "longValue", |x| x as i64),
930 Type::INT4 => opt!(i32, "longValue", |x| x as i64),
931 Type::INT8 => opt!(i64, "longValue", |x: i64| x),
932 Type::FLOAT4 => opt!(f32, "doubleValue", |x| x as f64),
933 Type::FLOAT8 => opt!(f64, "doubleValue", |x: f64| x),
934 Type::NUMERIC => strv!(rust_decimal::Decimal, |d: rust_decimal::Decimal| d
935 .to_string()),
936 Type::UUID => strv!(uuid::Uuid, |u: uuid::Uuid| u.to_string()),
937 Type::JSON | Type::JSONB => {
938 strv!(serde_json::Value, |j: serde_json::Value| j.to_string())
939 }
940 Type::TIMESTAMP => strv!(chrono::NaiveDateTime, |t: chrono::NaiveDateTime| {
941 pg_timestamp_text(
942 t.format("%Y-%m-%d %H:%M:%S"),
943 t.and_utc().timestamp_subsec_micros(),
944 )
945 }),
946 Type::TIMESTAMPTZ => strv!(chrono::DateTime<chrono::Utc>, |t: chrono::DateTime<
947 chrono::Utc,
948 >| format!(
949 "{}{}",
950 pg_timestamp_text(t.format("%Y-%m-%d %H:%M:%S"), t.timestamp_subsec_micros()),
951 t.format("%:z")
952 )),
953 Type::DATE => strv!(chrono::NaiveDate, |d: chrono::NaiveDate| d.to_string()),
954 Type::TIME => strv!(chrono::NaiveTime, |t: chrono::NaiveTime| t.to_string()),
955 Type::BYTEA => {
956 let v: Option<Vec<u8>> = row.try_get(i).unwrap_or(None);
957 match v {
958 Some(b) => json!({ "blobValue": b64_encode(&b) }),
959 None => json!({ "isNull": true }),
960 }
961 }
962 _ => {
963 let v: Option<String> = row.try_get(i).unwrap_or(None);
964 match v {
965 Some(s) => json!({ "stringValue": s }),
966 None => json!({ "isNull": true }),
967 }
968 }
969 }
970}
971
972async fn my_connect(conn: &DbConn) -> Result<mysql_async::Conn, AwsServiceError> {
973 use mysql_async::OptsBuilder;
974 let opts = OptsBuilder::default()
975 .ip_or_hostname(conn.host.as_str())
976 .tcp_port(conn.port)
977 .user(Some(&conn.user))
978 .pass(Some(&conn.password))
979 .db_name(Some(&conn.db_name));
980 mysql_async::Conn::new(opts)
981 .await
982 .map_err(|e| bad_request(format!("could not connect to database: {e}")))
983}
984
985async fn my_run(
986 c: &mut mysql_async::Conn,
987 sql: &str,
988 params: &[(String, SqlValue)],
989 include_metadata: bool,
990 format_json: bool,
991) -> Result<Value, AwsServiceError> {
992 use mysql_async::prelude::Queryable;
993 use mysql_async::{Column, Row};
994
995 let stmt = inline_params(sql, params, mysql_literal);
996 let mut out = Map::new();
997
998 if !returns_rows(&stmt) {
1002 c.query_drop(stmt.as_str())
1003 .await
1004 .map_err(|e| bad_request(format!("{e}")))?;
1005 out.insert("numberOfRecordsUpdated".into(), json!(c.affected_rows()));
1006 let generated: Option<u64> = c
1011 .query_first("SELECT LAST_INSERT_ID()")
1012 .await
1013 .ok()
1014 .flatten()
1015 .filter(|id: &u64| *id != 0);
1016 if let Some(id) = generated {
1017 out.insert(
1018 "generatedFields".into(),
1019 json!([{ "longValue": id as i64 }]),
1020 );
1021 }
1022 out.insert("records".into(), json!([]));
1023 return Ok(Value::Object(out));
1024 }
1025
1026 let result = c
1032 .query_iter(stmt.as_str())
1033 .await
1034 .map_err(|e| bad_request(format!("{e}")))?;
1035 let cols: std::sync::Arc<[Column]> = result
1036 .columns()
1037 .unwrap_or_else(|| std::sync::Arc::from(Vec::<Column>::new()));
1038 let rows: Vec<Row> = result
1039 .collect_and_drop::<Row>()
1040 .await
1041 .map_err(|e| bad_request(format!("{e}")))?;
1042 let affected = c.affected_rows();
1043
1044 let updated = if is_returning_dml(&stmt) {
1047 affected as i64
1048 } else {
1049 0
1050 };
1051 out.insert("numberOfRecordsUpdated".into(), json!(updated));
1052
1053 if include_metadata {
1054 out.insert("columnMetadata".into(), my_column_metadata(&cols));
1055 }
1056
1057 if rows.is_empty() {
1058 if format_json {
1059 out.insert("formattedRecords".into(), json!("[]"));
1060 } else {
1061 out.insert("records".into(), json!([]));
1062 }
1063 return Ok(Value::Object(out));
1064 }
1065
1066 let mut records: Vec<Value> = Vec::with_capacity(rows.len());
1067 let mut json_rows: Vec<Map<String, Value>> = Vec::new();
1068 for row in &rows {
1069 let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
1070 let mut jr = Map::new();
1071 for (i, col) in cols.iter().enumerate() {
1072 let field = my_field(row, i, col.column_type());
1073 if format_json {
1074 jr.insert(col.name_str().to_string(), field_to_plain(&field));
1075 }
1076 rec.push(field);
1077 }
1078 records.push(Value::Array(rec));
1079 if format_json {
1080 json_rows.push(jr);
1081 }
1082 }
1083 if format_json {
1084 out.insert(
1085 "formattedRecords".into(),
1086 json!(serde_json::to_string(&json_rows).unwrap_or_default()),
1087 );
1088 } else {
1089 out.insert("records".into(), Value::Array(records));
1090 }
1091 Ok(Value::Object(out))
1092}
1093
1094fn my_field(row: &mysql_async::Row, i: usize, ty: mysql_async::consts::ColumnType) -> Value {
1095 use mysql_async::Value as V;
1096 match row.as_ref(i) {
1097 Some(V::NULL) | None => json!({ "isNull": true }),
1098 Some(V::Int(n)) => json!({ "longValue": n }),
1100 Some(V::UInt(n)) => json!({ "longValue": *n as i64 }),
1101 Some(V::Float(f)) => json!({ "doubleValue": *f as f64 }),
1102 Some(V::Double(d)) => json!({ "doubleValue": d }),
1103 Some(V::Bytes(b)) => my_bytes_field(b, ty),
1107 Some(V::Date(y, mo, d, h, mi, s, us)) => {
1110 json!({ "stringValue": format_mysql_datetime(*y, *mo, *d, *h, *mi, *s, *us) })
1111 }
1112 Some(V::Time(neg, days, h, mi, s, us)) => {
1113 json!({ "stringValue": format_mysql_time(*neg, *days, *h, *mi, *s, *us) })
1114 }
1115 }
1116}
1117
1118fn my_bytes_field(b: &[u8], ty: mysql_async::consts::ColumnType) -> Value {
1123 use mysql_async::consts::ColumnType::*;
1124 let as_str = std::str::from_utf8(b).ok();
1125 match ty {
1126 MYSQL_TYPE_TINY | MYSQL_TYPE_SHORT | MYSQL_TYPE_LONG | MYSQL_TYPE_LONGLONG
1127 | MYSQL_TYPE_INT24 | MYSQL_TYPE_YEAR => {
1128 if let Some(n) = as_str.and_then(|s| s.parse::<i64>().ok()) {
1129 return json!({ "longValue": n });
1130 }
1131 }
1132 MYSQL_TYPE_FLOAT | MYSQL_TYPE_DOUBLE => {
1133 if let Some(d) = as_str.and_then(|s| s.parse::<f64>().ok()) {
1134 return json!({ "doubleValue": d });
1135 }
1136 }
1137 _ => {}
1138 }
1139 match as_str {
1140 Some(s) => json!({ "stringValue": s }),
1141 None => json!({ "blobValue": b64_encode(b) }),
1142 }
1143}
1144
1145#[allow(clippy::too_many_arguments)]
1148fn format_mysql_datetime(
1149 year: u16,
1150 month: u8,
1151 day: u8,
1152 hour: u8,
1153 min: u8,
1154 sec: u8,
1155 micros: u32,
1156) -> String {
1157 let date = format!("{year:04}-{month:02}-{day:02}");
1158 if hour == 0 && min == 0 && sec == 0 && micros == 0 {
1159 return date;
1160 }
1161 if micros == 0 {
1162 format!("{date} {hour:02}:{min:02}:{sec:02}")
1163 } else {
1164 format!("{date} {hour:02}:{min:02}:{sec:02}.{micros:06}")
1165 }
1166}
1167
1168fn format_mysql_time(neg: bool, days: u32, hours: u8, mins: u8, secs: u8, micros: u32) -> String {
1170 let sign = if neg { "-" } else { "" };
1171 let total_hours = days * 24 + hours as u32;
1172 if micros == 0 {
1173 format!("{sign}{total_hours:02}:{mins:02}:{secs:02}")
1174 } else {
1175 format!("{sign}{total_hours:02}:{mins:02}:{secs:02}.{micros:06}")
1176 }
1177}
1178
1179fn field_to_plain(field: &Value) -> Value {
1181 let o = field.as_object();
1182 if o.and_then(|m| m.get("isNull")).is_some() {
1183 return Value::Null;
1184 }
1185 for k in [
1186 "stringValue",
1187 "longValue",
1188 "doubleValue",
1189 "booleanValue",
1190 "blobValue",
1191 ] {
1192 if let Some(v) = o.and_then(|m| m.get(k)) {
1193 return v.clone();
1194 }
1195 }
1196 Value::Null
1197}
1198
1199fn b64_encode(b: &[u8]) -> String {
1200 use base64::Engine;
1201 base64::engine::general_purpose::STANDARD.encode(b)
1202}
1203fn b64_decode(s: &str) -> Vec<u8> {
1204 use base64::Engine;
1205 base64::engine::general_purpose::STANDARD
1206 .decode(s)
1207 .unwrap_or_default()
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::*;
1213
1214 #[test]
1215 fn is_returning_dml_matches_only_dml_returning() {
1216 assert!(is_returning_dml("INSERT INTO t VALUES (1) RETURNING id"));
1217 assert!(is_returning_dml(" update t set x = 1 returning *"));
1218 assert!(is_returning_dml("DELETE FROM t WHERE id = 1 RETURNING id"));
1219 assert!(!is_returning_dml("SELECT 1"));
1221 assert!(!is_returning_dml("SELECT returning_col FROM t"));
1222 assert!(!is_returning_dml("INSERT INTO t VALUES (1)"));
1224 }
1225
1226 #[test]
1227 fn batch_update_result_carries_generated_fields() {
1228 let out = json!({
1231 "numberOfRecordsUpdated": 1,
1232 "generatedFields": [{ "longValue": 42 }],
1233 "records": [],
1234 });
1235 assert_eq!(
1236 batch_update_result(&out),
1237 json!({ "generatedFields": [{ "longValue": 42 }] })
1238 );
1239 }
1240
1241 #[test]
1242 fn batch_update_result_defaults_to_empty_generated_fields() {
1243 let out = json!({ "numberOfRecordsUpdated": 1, "records": [] });
1246 assert_eq!(batch_update_result(&out), json!({ "generatedFields": [] }));
1247 }
1248
1249 fn idle_entry(last_used: Instant) -> Arc<TxnEntry> {
1250 Arc::new(TxnEntry {
1252 conn: Mutex::new(None),
1253 last_used: StdMutex::new(last_used),
1254 in_flight: AtomicU64::new(0),
1255 })
1256 }
1257
1258 #[tokio::test(start_paused = true)]
1259 async fn reaper_rolls_back_idle_transaction() {
1260 let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
1261 let base = Instant::now();
1262 transactions
1263 .lock()
1264 .await
1265 .insert("tx1".to_string(), idle_entry(base));
1266 tokio::time::advance(TXN_IDLE_TIMEOUT + Duration::from_secs(1)).await;
1268 reap_once(&transactions).await;
1269 assert!(
1270 transactions.lock().await.is_empty(),
1271 "a genuinely idle transaction is reaped"
1272 );
1273 }
1274
1275 #[tokio::test(start_paused = true)]
1276 async fn reaper_spares_transaction_with_in_flight_statement() {
1277 let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
1282 let base = Instant::now();
1283 let entry = idle_entry(base);
1284 transactions
1285 .lock()
1286 .await
1287 .insert("tx1".to_string(), entry.clone());
1288 tokio::time::advance(TXN_IDLE_TIMEOUT + Duration::from_secs(1)).await;
1289 entry.in_flight.fetch_add(1, Ordering::SeqCst);
1292 reap_once(&transactions).await;
1293 assert!(
1294 transactions.lock().await.contains_key("tx1"),
1295 "a transaction with an in-flight statement is not reaped despite a stale last_used"
1296 );
1297 }
1298
1299 #[tokio::test(start_paused = true)]
1300 async fn reaper_rechecks_staleness_before_rollback() {
1301 let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
1302 let base = Instant::now();
1303 let entry = idle_entry(base);
1304 transactions
1305 .lock()
1306 .await
1307 .insert("tx1".to_string(), entry.clone());
1308 tokio::time::advance(TXN_IDLE_TIMEOUT + Duration::from_secs(1)).await;
1309 let stale = collect_stale(&transactions, Instant::now()).await;
1311 assert_eq!(stale.len(), 1, "entry sampled as stale");
1312 entry.touch();
1314 reap_stale(&transactions, stale).await;
1315 assert!(
1316 transactions.lock().await.contains_key("tx1"),
1317 "a reactivated transaction is NOT rolled back (TOCTOU guarded)"
1318 );
1319 }
1320}