Skip to main content

fakecloud_rds_data/
service.rs

1//! RDS Data API (`rds-data`) restJson1 dispatch + real SQL execution.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::Mutex as StdMutex;
6
7use async_trait::async_trait;
8use http::{Method, StatusCode};
9use serde_json::{json, Map, Value};
10use tokio::sync::Mutex;
11use tokio::time::{Duration, Instant};
12
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_rds::SharedRdsState;
15
16const SUPPORTED_ACTIONS: &[&str] = &[
17    "ExecuteStatement",
18    "BatchExecuteStatement",
19    "BeginTransaction",
20    "CommitTransaction",
21    "RollbackTransaction",
22    "ExecuteSql",
23];
24
25/// Idle transactions are rolled back and their connection released after this
26/// long with no statement, mirroring the AWS RDS Data API's ~3-minute idle
27/// transaction timeout. Without this, a client that begins a transaction and
28/// never commits (crash, dropped handle) would leak a backing DB connection
29/// forever, eventually exhausting the container's `max_connections`.
30const TXN_IDLE_TIMEOUT: Duration = Duration::from_secs(180);
31
32/// Connection parameters captured from a resolved `DbInstance` (owned so the
33/// state read-lock is dropped before we touch the network).
34struct DbConn {
35    engine: String,
36    host: String,
37    port: u16,
38    user: String,
39    password: String,
40    db_name: String,
41    /// Optional schema (`search_path` on Postgres); ignored on MySQL where the
42    /// schema is the database.
43    schema: Option<String>,
44}
45
46/// A live connection held open across requests for the lifetime of a
47/// transaction. Postgres drives its connection on a spawned task that lives as
48/// long as the `Client`; MySQL owns its socket.
49enum HeldConn {
50    Pg(tokio_postgres::Client),
51    MySql(mysql_async::Conn),
52}
53
54/// One open transaction. The connection has its own lock so the global
55/// transactions-map lock is never held across an awaited SQL statement (a slow
56/// statement in one transaction must not block begin/commit/rollback of
57/// another). `last_used` drives the idle reaper.
58struct TxnEntry {
59    /// `None` once the transaction has been committed/rolled-back or reaped.
60    conn: Mutex<Option<HeldConn>>,
61    last_used: StdMutex<Instant>,
62}
63
64impl TxnEntry {
65    fn touch(&self) {
66        if let Ok(mut t) = self.last_used.lock() {
67            *t = Instant::now();
68        }
69    }
70}
71
72type TxnMap = Arc<Mutex<HashMap<String, Arc<TxnEntry>>>>;
73
74pub struct RdsDataService {
75    rds_state: SharedRdsState,
76    /// Open transactions: `transactionId` -> the held connection running its
77    /// `BEGIN`.
78    transactions: TxnMap,
79}
80
81impl RdsDataService {
82    pub fn new(rds_state: SharedRdsState) -> Self {
83        let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
84        // Reap idle transactions so abandoned ones don't leak DB connections.
85        // Only spawn when a tokio runtime is present (it always is in the
86        // server; absent in some unit-test constructions).
87        if let Ok(handle) = tokio::runtime::Handle::try_current() {
88            handle.spawn(reap_idle_transactions(transactions.clone()));
89        }
90        Self {
91            rds_state,
92            transactions,
93        }
94    }
95
96    /// Map the restJson1 request (`POST /<Op>`) to its operation name.
97    fn resolve_action(req: &AwsRequest) -> Option<&'static str> {
98        if req.method != Method::POST {
99            return None;
100        }
101        match req.path_segments.first().map(String::as_str)? {
102            "Execute" => Some("ExecuteStatement"),
103            "BatchExecute" => Some("BatchExecuteStatement"),
104            "BeginTransaction" => Some("BeginTransaction"),
105            "CommitTransaction" => Some("CommitTransaction"),
106            "RollbackTransaction" => Some("RollbackTransaction"),
107            "ExecuteSql" => Some("ExecuteSql"),
108            _ => None,
109        }
110    }
111
112    /// Resolve a `resourceArn` (a DB instance or Aurora cluster ARN) to live
113    /// connection parameters from the RDS service state. Returns the writer
114    /// instance for a cluster ARN.
115    fn resolve_conn(&self, account_id: &str, resource_arn: &str) -> Option<DbConn> {
116        let cluster_id = resource_arn
117            .rsplit_once(":cluster:")
118            .map(|(_, id)| id.to_string());
119        let accounts = self.rds_state.read();
120        let state = accounts.get(account_id)?;
121        let inst = state.instances.values().find(|i| {
122            i.db_instance_arn == resource_arn
123                || cluster_id
124                    .as_deref()
125                    .is_some_and(|c| i.db_cluster_identifier.as_deref() == Some(c))
126        })?;
127        Some(DbConn {
128            engine: inst.engine.clone(),
129            // Use the address the RDS runtime actually recorded for this
130            // instance (Docker sibling host or k8s Pod IP). Re-deriving a
131            // Docker-only sibling host here would break the k8s backend and
132            // shell out to `docker info` on every request.
133            host: inst.endpoint_address.clone(),
134            port: inst.host_port,
135            user: inst.master_username.clone(),
136            password: inst.master_user_password.clone(),
137            db_name: inst.db_name.clone().unwrap_or_default(),
138            schema: None,
139        })
140    }
141
142    /// Common front-matter for the credentialed ops: validate `secretArn`,
143    /// resolve the `resourceArn` to a connection, and apply the request's
144    /// `database`/`schema` overrides (AWS lets a single resource serve many
145    /// databases — Aurora clusters often have no default DB name at all).
146    fn require_conn(&self, req: &AwsRequest, body: &Value) -> Result<DbConn, AwsServiceError> {
147        let resource_arn = body
148            .get("resourceArn")
149            .and_then(Value::as_str)
150            .ok_or_else(|| bad_request("resourceArn is required"))?;
151        // secretArn is required by AWS but fakecloud trusts the resourceArn for
152        // credential resolution (the secret would carry the same master creds).
153        if body.get("secretArn").and_then(Value::as_str).is_none() {
154            return Err(bad_request("secretArn is required"));
155        }
156        let mut conn = self
157            .resolve_conn(&req.account_id, resource_arn)
158            .ok_or_else(|| {
159                bad_request(format!(
160                    "HttpEndpoint is not enabled for resource {resource_arn} (no such DB)"
161                ))
162            })?;
163        if let Some(db) = body.get("database").and_then(Value::as_str) {
164            if !db.is_empty() {
165                conn.db_name = db.to_string();
166            }
167        }
168        conn.schema = body
169            .get("schema")
170            .and_then(Value::as_str)
171            .filter(|s| !s.is_empty())
172            .map(str::to_string);
173        Ok(conn)
174    }
175
176    async fn execute_statement(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
177        let body = req.json_body();
178        let sql = body
179            .get("sql")
180            .and_then(Value::as_str)
181            .ok_or_else(|| bad_request("sql is required"))?;
182        let include_metadata = body
183            .get("includeResultMetadata")
184            .and_then(Value::as_bool)
185            .unwrap_or(false);
186        let format_json = body
187            .get("formatRecordsAs")
188            .and_then(Value::as_str)
189            .map(|f| f == "JSON")
190            .unwrap_or(false);
191        let params = parse_parameters(body.get("parameters"));
192
193        // Inside a transaction: run on the held connection so the statement
194        // shares the open transaction's visibility and is committed/rolled back
195        // atomically with the others.
196        if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
197            // Validate the resource still resolves (AWS still checks it), but the
198            // actual execution rides the held connection.
199            self.require_conn(req, &body)?;
200            let entry = self.txn_entry(txid).await?;
201            entry.touch();
202            // Lock only this transaction's connection, not the whole map, so a
203            // slow statement here can't block other transactions.
204            let mut guard = entry.conn.lock().await;
205            let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
206            let value = match held {
207                HeldConn::Pg(client) => {
208                    pg_run(client, sql, &params, include_metadata, format_json).await?
209                }
210                HeldConn::MySql(conn) => {
211                    my_run(conn, sql, &params, include_metadata, format_json).await?
212                }
213            };
214            return Ok(AwsResponse::ok_json(value));
215        }
216
217        let conn = self.require_conn(req, &body)?;
218        let engine = conn.engine.to_lowercase();
219        let value = if engine.contains("postgres") {
220            let client = pg_connect(&conn).await?;
221            pg_run(&client, sql, &params, include_metadata, format_json).await?
222        } else if is_mysql(&engine) {
223            let mut c = my_connect(&conn).await?;
224            let v = my_run(&mut c, sql, &params, include_metadata, format_json).await;
225            let _ = c.disconnect().await;
226            v?
227        } else {
228            return Err(unsupported_engine(&conn.engine));
229        };
230        Ok(AwsResponse::ok_json(value))
231    }
232
233    async fn begin_transaction(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
234        let body = req.json_body();
235        let conn = self.require_conn(req, &body)?;
236        let engine = conn.engine.to_lowercase();
237
238        let held = if engine.contains("postgres") {
239            let client = pg_connect(&conn).await?;
240            client
241                .batch_execute("BEGIN")
242                .await
243                .map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
244            HeldConn::Pg(client)
245        } else if is_mysql(&engine) {
246            use mysql_async::prelude::Queryable;
247            let mut c = my_connect(&conn).await?;
248            c.query_drop("START TRANSACTION")
249                .await
250                .map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
251            HeldConn::MySql(c)
252        } else {
253            return Err(unsupported_engine(&conn.engine));
254        };
255
256        let txid = gen_transaction_id();
257        let entry = Arc::new(TxnEntry {
258            conn: Mutex::new(Some(held)),
259            last_used: StdMutex::new(Instant::now()),
260        });
261        self.transactions.lock().await.insert(txid.clone(), entry);
262        Ok(AwsResponse::ok_json(json!({ "transactionId": txid })))
263    }
264
265    /// Look up an open transaction's entry, cloning the `Arc` so the map lock is
266    /// released before the caller locks the connection itself.
267    async fn txn_entry(&self, txid: &str) -> Result<Arc<TxnEntry>, AwsServiceError> {
268        self.transactions
269            .lock()
270            .await
271            .get(txid)
272            .cloned()
273            .ok_or_else(|| not_found_txn(txid))
274    }
275
276    /// Shared body for Commit/Rollback: pull the held connection out of the map
277    /// and run the terminal SQL on it.
278    async fn finish_transaction(
279        &self,
280        req: &AwsRequest,
281        sql_keyword: &str,
282        status: &str,
283    ) -> Result<AwsResponse, AwsServiceError> {
284        let body = req.json_body();
285        let txid = body
286            .get("transactionId")
287            .and_then(Value::as_str)
288            .ok_or_else(|| bad_request("transactionId is required"))?;
289        let entry = self
290            .transactions
291            .lock()
292            .await
293            .remove(txid)
294            .ok_or_else(|| not_found_txn(txid))?;
295        // Take ownership of the connection (waiting for any in-flight statement
296        // on it to finish) and run the terminal SQL.
297        let held = entry
298            .conn
299            .lock()
300            .await
301            .take()
302            .ok_or_else(|| not_found_txn(txid))?;
303        finish_held(held, sql_keyword).await?;
304        Ok(AwsResponse::ok_json(json!({ "transactionStatus": status })))
305    }
306
307    async fn batch_execute_statement(
308        &self,
309        req: &AwsRequest,
310    ) -> Result<AwsResponse, AwsServiceError> {
311        let body = req.json_body();
312        let sql = body
313            .get("sql")
314            .and_then(Value::as_str)
315            .ok_or_else(|| bad_request("sql is required"))?;
316        let sets = parse_parameter_sets(body.get("parameterSets"));
317
318        // Run every parameter set on one connection so the whole batch shares a
319        // transaction when `transactionId` is given.
320        if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
321            self.require_conn(req, &body)?;
322            let entry = self.txn_entry(txid).await?;
323            entry.touch();
324            let mut guard = entry.conn.lock().await;
325            let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
326            let mut results = Vec::with_capacity(sets.len().max(1));
327            match held {
328                HeldConn::Pg(client) => {
329                    for set in run_each(&sets) {
330                        pg_run(client, sql, set, false, false).await?;
331                        results.push(json!({ "generatedFields": [] }));
332                    }
333                }
334                HeldConn::MySql(conn) => {
335                    for set in run_each(&sets) {
336                        my_run(conn, sql, set, false, false).await?;
337                        results.push(json!({ "generatedFields": [] }));
338                    }
339                }
340            }
341            return Ok(AwsResponse::ok_json(json!({ "updateResults": results })));
342        }
343
344        let conn = self.require_conn(req, &body)?;
345        let engine = conn.engine.to_lowercase();
346        let mut results = Vec::with_capacity(sets.len().max(1));
347        if engine.contains("postgres") {
348            let client = pg_connect(&conn).await?;
349            for set in run_each(&sets) {
350                pg_run(&client, sql, set, false, false).await?;
351                results.push(json!({ "generatedFields": [] }));
352            }
353        } else if is_mysql(&engine) {
354            let mut c = my_connect(&conn).await?;
355            for set in run_each(&sets) {
356                if let Err(e) = my_run(&mut c, sql, set, false, false).await {
357                    let _ = c.disconnect().await;
358                    return Err(e);
359                }
360                results.push(json!({ "generatedFields": [] }));
361            }
362            let _ = c.disconnect().await;
363        } else {
364            return Err(unsupported_engine(&conn.engine));
365        }
366        Ok(AwsResponse::ok_json(json!({ "updateResults": results })))
367    }
368}
369
370#[async_trait]
371impl AwsService for RdsDataService {
372    fn service_name(&self) -> &str {
373        "rds-data"
374    }
375
376    fn supported_actions(&self) -> &[&str] {
377        SUPPORTED_ACTIONS
378    }
379
380    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
381        let Some(action) = Self::resolve_action(&req) else {
382            return Err(AwsServiceError::aws_error(
383                StatusCode::NOT_FOUND,
384                "ResourceNotFoundException",
385                format!("Unknown operation: {} {}", req.method, req.raw_path),
386            ));
387        };
388        match action {
389            "ExecuteStatement" => self.execute_statement(&req).await,
390            "BatchExecuteStatement" => self.batch_execute_statement(&req).await,
391            "BeginTransaction" => self.begin_transaction(&req).await,
392            "CommitTransaction" => {
393                self.finish_transaction(&req, "COMMIT", "Transaction Committed")
394                    .await
395            }
396            "RollbackTransaction" => {
397                self.finish_transaction(&req, "ROLLBACK", "Rollback Complete")
398                    .await
399            }
400            // ExecuteSql is the deprecated, secret-less precursor to
401            // ExecuteStatement and was removed from the public API. Return an
402            // honest error rather than a fake success.
403            other => Err(AwsServiceError::aws_error(
404                StatusCode::BAD_REQUEST,
405                "BadRequestException",
406                format!("rds-data operation {other} is not supported"),
407            )),
408        }
409    }
410}
411
412fn bad_request(msg: impl Into<String>) -> AwsServiceError {
413    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg.into())
414}
415
416fn not_found_txn(txid: &str) -> AwsServiceError {
417    AwsServiceError::aws_error(
418        StatusCode::BAD_REQUEST,
419        "BadRequestException",
420        format!("Transaction {txid} is not found"),
421    )
422}
423
424fn unsupported_engine(engine: &str) -> AwsServiceError {
425    bad_request(format!("RDS Data API is not supported for engine {engine}"))
426}
427
428fn is_mysql(engine_lower: &str) -> bool {
429    engine_lower.contains("mysql") || engine_lower.contains("maria")
430}
431
432/// AWS transaction ids are long opaque base64-ish tokens; mirror the shape.
433fn gen_transaction_id() -> String {
434    use rand::RngCore;
435    let mut bytes = [0u8; 48];
436    rand::thread_rng().fill_bytes(&mut bytes);
437    b64_encode(&bytes)
438}
439
440/// Run the terminal SQL (`COMMIT`/`ROLLBACK`) on a connection taken out of a
441/// transaction entry and release it.
442async fn finish_held(held: HeldConn, sql_keyword: &str) -> Result<(), AwsServiceError> {
443    match held {
444        HeldConn::Pg(client) => {
445            client
446                .batch_execute(sql_keyword)
447                .await
448                .map_err(|e| bad_request(format!("{e}")))?;
449        }
450        HeldConn::MySql(mut conn) => {
451            use mysql_async::prelude::Queryable;
452            conn.query_drop(sql_keyword)
453                .await
454                .map_err(|e| bad_request(format!("{e}")))?;
455            let _ = conn.disconnect().await;
456        }
457    }
458    Ok(())
459}
460
461/// Background sweep: roll back and release any transaction idle longer than
462/// [`TXN_IDLE_TIMEOUT`], so an abandoned `BeginTransaction` never leaks its
463/// backing DB connection forever.
464async fn reap_idle_transactions(transactions: TxnMap) {
465    let mut tick = tokio::time::interval(Duration::from_secs(30));
466    loop {
467        tick.tick().await;
468        let now = Instant::now();
469        let stale: Vec<(String, Arc<TxnEntry>)> = {
470            let map = transactions.lock().await;
471            map.iter()
472                .filter(|(_, e)| {
473                    e.last_used
474                        .lock()
475                        .map(|t| now.duration_since(*t) > TXN_IDLE_TIMEOUT)
476                        .unwrap_or(false)
477                })
478                .map(|(id, e)| (id.clone(), e.clone()))
479                .collect()
480        };
481        for (id, entry) in stale {
482            transactions.lock().await.remove(&id);
483            if let Some(held) = entry.conn.lock().await.take() {
484                let _ = finish_held(held, "ROLLBACK").await;
485            }
486        }
487    }
488}
489
490/// A single positional SQL parameter resolved from an AWS `SqlParameter`.
491enum SqlValue {
492    Null,
493    Bool(bool),
494    Long(i64),
495    Double(f64),
496    Text(String),
497    Blob(Vec<u8>),
498}
499
500type Params = Vec<(String, SqlValue)>;
501
502/// Parse `parameters[]` into `(name, value)` pairs in request order.
503fn parse_parameters(params: Option<&Value>) -> Params {
504    let Some(arr) = params.and_then(Value::as_array) else {
505        return Vec::new();
506    };
507    arr.iter()
508        .filter_map(|p| {
509            let name = p.get("name").and_then(Value::as_str)?.to_string();
510            let v = p.get("value")?;
511            let sv = if v.get("isNull").and_then(Value::as_bool) == Some(true) {
512                SqlValue::Null
513            } else if let Some(b) = v.get("booleanValue").and_then(Value::as_bool) {
514                SqlValue::Bool(b)
515            } else if let Some(n) = v.get("longValue").and_then(Value::as_i64) {
516                SqlValue::Long(n)
517            } else if let Some(d) = v.get("doubleValue").and_then(Value::as_f64) {
518                SqlValue::Double(d)
519            } else if let Some(s) = v.get("stringValue").and_then(Value::as_str) {
520                SqlValue::Text(s.to_string())
521            } else if let Some(b) = v.get("blobValue").and_then(Value::as_str) {
522                // blobValue is base64 in the Data API JSON.
523                SqlValue::Blob(b64_decode(b))
524            } else {
525                SqlValue::Null
526            };
527            Some((name, sv))
528        })
529        .collect()
530}
531
532/// Parse `parameterSets` (a list of parameter lists) for BatchExecuteStatement.
533fn parse_parameter_sets(sets: Option<&Value>) -> Vec<Params> {
534    let Some(arr) = sets.and_then(Value::as_array) else {
535        return Vec::new();
536    };
537    arr.iter().map(|s| parse_parameters(Some(s))).collect()
538}
539
540/// A batch with no parameter sets still runs the statement once (AWS treats an
541/// empty `parameterSets` as a single parameterless execution).
542fn run_each(sets: &[Params]) -> Vec<&[(String, SqlValue)]> {
543    if sets.is_empty() {
544        vec![&[]]
545    } else {
546        sets.iter().map(Vec::as_slice).collect()
547    }
548}
549
550/// Substitute AWS `:name` named placeholders with the SQL literal rendering of
551/// each parameter. We inline (rather than bind) so the engine parses each value
552/// with the *column's* type — binding a Data API `longValue` (i64) to an int4
553/// column otherwise fails type-serialization. Data API parameters are trusted
554/// server-side fixtures, and results still come back fully typed because we run
555/// the literal-substituted SQL through the typed `query` path.
556fn inline_params(
557    sql: &str,
558    params: &[(String, SqlValue)],
559    lit: impl Fn(&SqlValue) -> String,
560) -> String {
561    let mut out = String::with_capacity(sql.len());
562    let bytes = sql.as_bytes();
563    let mut i = 0;
564    while i < bytes.len() {
565        if bytes[i] == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
566            let mut j = i + 1;
567            while j < bytes.len() && is_ident_char(bytes[j]) {
568                j += 1;
569            }
570            let name = &sql[i + 1..j];
571            if let Some((_, v)) = params.iter().find(|(n, _)| n == name) {
572                out.push_str(&lit(v));
573                i = j;
574                continue;
575            }
576        }
577        // Copy one whole UTF-8 character. `:` and identifier bytes are ASCII, so
578        // the placeholder scan above only ever fires on a char boundary; here we
579        // must advance by the full character width, not a single byte, or
580        // multi-byte literals (e.g. 'café') would be corrupted into mojibake.
581        let ch = sql[i..]
582            .chars()
583            .next()
584            .expect("byte index is on a char boundary");
585        out.push(ch);
586        i += ch.len_utf8();
587    }
588    out
589}
590
591fn sql_quote(s: &str) -> String {
592    format!("'{}'", s.replace('\'', "''"))
593}
594
595/// Quote an SQL identifier (schema/table name) so a caller-supplied `schema`
596/// can't break out of the `SET search_path` statement.
597fn quote_ident(s: &str) -> String {
598    format!("\"{}\"", s.replace('"', "\"\""))
599}
600
601/// SQL literal for postgres.
602fn pg_literal(v: &SqlValue) -> String {
603    match v {
604        SqlValue::Null => "NULL".to_string(),
605        SqlValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
606        SqlValue::Long(n) => n.to_string(),
607        SqlValue::Double(d) => d.to_string(),
608        SqlValue::Text(s) => sql_quote(s),
609        SqlValue::Blob(b) => format!("'\\x{}'::bytea", hex(b)),
610    }
611}
612
613/// SQL literal for mysql/mariadb.
614fn mysql_literal(v: &SqlValue) -> String {
615    match v {
616        SqlValue::Null => "NULL".to_string(),
617        SqlValue::Bool(b) => if *b { "1" } else { "0" }.to_string(),
618        SqlValue::Long(n) => n.to_string(),
619        SqlValue::Double(d) => d.to_string(),
620        SqlValue::Text(s) => sql_quote(s),
621        SqlValue::Blob(b) => format!("x'{}'", hex(b)),
622    }
623}
624
625/// Does the SQL statement return a result set (vs. an affected-rows write)?
626fn returns_rows(sql: &str) -> bool {
627    let head = sql.trim_start().to_ascii_uppercase();
628    head.starts_with("SELECT")
629        || head.starts_with("WITH")
630        || head.starts_with("SHOW")
631        || head.starts_with("VALUES")
632        || head.starts_with("TABLE")
633        || head.starts_with("EXPLAIN")
634        || head.contains(" RETURNING ")
635}
636
637fn hex(b: &[u8]) -> String {
638    let mut s = String::with_capacity(b.len() * 2);
639    for byte in b {
640        s.push_str(&format!("{byte:02x}"));
641    }
642    s
643}
644
645fn is_ident_start(b: u8) -> bool {
646    b.is_ascii_alphabetic() || b == b'_'
647}
648fn is_ident_char(b: u8) -> bool {
649    b.is_ascii_alphanumeric() || b == b'_'
650}
651
652async fn pg_connect(conn: &DbConn) -> Result<tokio_postgres::Client, AwsServiceError> {
653    use tokio_postgres::NoTls;
654    let cs = format!(
655        "host={} port={} user={} password={} dbname={}",
656        conn.host, conn.port, conn.user, conn.password, conn.db_name
657    );
658    let (client, connection) = tokio_postgres::connect(&cs, NoTls)
659        .await
660        .map_err(|e| bad_request(format!("could not connect to database: {e}")))?;
661    tokio::spawn(async move {
662        let _ = connection.await;
663    });
664    if let Some(schema) = &conn.schema {
665        client
666            .batch_execute(&format!("SET search_path TO {}", quote_ident(schema)))
667            .await
668            .map_err(|e| bad_request(format!("could not set schema: {e}")))?;
669    }
670    Ok(client)
671}
672
673async fn pg_run(
674    client: &tokio_postgres::Client,
675    sql: &str,
676    params: &[(String, SqlValue)],
677    include_metadata: bool,
678    format_json: bool,
679) -> Result<Value, AwsServiceError> {
680    let stmt = inline_params(sql, params, pg_literal);
681    let no_params: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[];
682
683    let mut out = Map::new();
684    // Route by statement kind so a write runs EXACTLY once (query() executes the
685    // statement too, so running execute() after would double-apply it).
686    if !returns_rows(&stmt) {
687        let affected = client
688            .execute(stmt.as_str(), no_params)
689            .await
690            .map_err(|e| bad_request(format!("{e}")))?;
691        out.insert("numberOfRecordsUpdated".into(), json!(affected));
692        out.insert("records".into(), json!([]));
693        return Ok(Value::Object(out));
694    }
695
696    let rows = client
697        .query(stmt.as_str(), no_params)
698        .await
699        .map_err(|e| bad_request(format!("{e}")))?;
700    if rows.is_empty() {
701        out.insert("numberOfRecordsUpdated".into(), json!(0));
702        if format_json {
703            out.insert("formattedRecords".into(), json!("[]"));
704        } else {
705            out.insert("records".into(), json!([]));
706        }
707        return Ok(Value::Object(out));
708    }
709
710    let cols = rows[0].columns();
711    if include_metadata {
712        let md: Vec<Value> = cols
713            .iter()
714            .map(|c| {
715                json!({
716                    "name": c.name(),
717                    "label": c.name(),
718                    "typeName": c.type_().name(),
719                    "nullable": 2, // unknown
720                })
721            })
722            .collect();
723        out.insert("columnMetadata".into(), Value::Array(md));
724    }
725
726    let mut records: Vec<Value> = Vec::with_capacity(rows.len());
727    let mut json_rows: Vec<Map<String, Value>> = Vec::new();
728    for row in &rows {
729        let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
730        let mut jr = Map::new();
731        for (i, col) in cols.iter().enumerate() {
732            let field = pg_field(row, i, col.type_());
733            if format_json {
734                jr.insert(col.name().to_string(), field_to_plain(&field));
735            }
736            rec.push(field);
737        }
738        records.push(Value::Array(rec));
739        if format_json {
740            json_rows.push(jr);
741        }
742    }
743    if format_json {
744        out.insert(
745            "formattedRecords".into(),
746            json!(serde_json::to_string(&json_rows).unwrap_or_default()),
747        );
748    } else {
749        out.insert("records".into(), Value::Array(records));
750    }
751    Ok(Value::Object(out))
752}
753
754/// Append a fractional-seconds suffix only when non-zero, with trailing zeros
755/// trimmed, matching Postgres's own `timestamp::text` rendering.
756fn pg_timestamp_text(base: impl std::fmt::Display, micros: u32) -> String {
757    if micros == 0 {
758        return base.to_string();
759    }
760    let frac = format!("{micros:06}");
761    let frac = frac.trim_end_matches('0');
762    format!("{base}.{frac}")
763}
764
765fn pg_field(row: &tokio_postgres::Row, i: usize, ty: &tokio_postgres::types::Type) -> Value {
766    use tokio_postgres::types::Type;
767    macro_rules! opt {
768        ($t:ty, $key:expr, $conv:expr) => {{
769            let v: Option<$t> = row.try_get(i).unwrap_or(None);
770            match v {
771                Some(x) => json!({ $key: $conv(x) }),
772                None => json!({ "isNull": true }),
773            }
774        }};
775    }
776    // Types that AWS surfaces as `stringValue` (numeric/decimal, temporal,
777    // UUID, JSON): decode them with their real Rust representation and render
778    // the canonical text, rather than letting them fall through to the
779    // `String` arm — which fails for these binary-format columns and would
780    // wrongly report `isNull`.
781    macro_rules! strv {
782        ($t:ty, $conv:expr) => {{
783            let v: Option<$t> = row.try_get(i).unwrap_or(None);
784            match v {
785                Some(x) => json!({ "stringValue": $conv(x) }),
786                None => json!({ "isNull": true }),
787            }
788        }};
789    }
790    match *ty {
791        Type::BOOL => opt!(bool, "booleanValue", |x| x),
792        Type::INT2 => opt!(i16, "longValue", |x| x as i64),
793        Type::INT4 => opt!(i32, "longValue", |x| x as i64),
794        Type::INT8 => opt!(i64, "longValue", |x: i64| x),
795        Type::FLOAT4 => opt!(f32, "doubleValue", |x| x as f64),
796        Type::FLOAT8 => opt!(f64, "doubleValue", |x: f64| x),
797        Type::NUMERIC => strv!(rust_decimal::Decimal, |d: rust_decimal::Decimal| d
798            .to_string()),
799        Type::UUID => strv!(uuid::Uuid, |u: uuid::Uuid| u.to_string()),
800        Type::JSON | Type::JSONB => {
801            strv!(serde_json::Value, |j: serde_json::Value| j.to_string())
802        }
803        Type::TIMESTAMP => strv!(chrono::NaiveDateTime, |t: chrono::NaiveDateTime| {
804            pg_timestamp_text(
805                t.format("%Y-%m-%d %H:%M:%S"),
806                t.and_utc().timestamp_subsec_micros(),
807            )
808        }),
809        Type::TIMESTAMPTZ => strv!(chrono::DateTime<chrono::Utc>, |t: chrono::DateTime<
810            chrono::Utc,
811        >| format!(
812            "{}{}",
813            pg_timestamp_text(t.format("%Y-%m-%d %H:%M:%S"), t.timestamp_subsec_micros()),
814            t.format("%:z")
815        )),
816        Type::DATE => strv!(chrono::NaiveDate, |d: chrono::NaiveDate| d.to_string()),
817        Type::TIME => strv!(chrono::NaiveTime, |t: chrono::NaiveTime| t.to_string()),
818        Type::BYTEA => {
819            let v: Option<Vec<u8>> = row.try_get(i).unwrap_or(None);
820            match v {
821                Some(b) => json!({ "blobValue": b64_encode(&b) }),
822                None => json!({ "isNull": true }),
823            }
824        }
825        _ => {
826            let v: Option<String> = row.try_get(i).unwrap_or(None);
827            match v {
828                Some(s) => json!({ "stringValue": s }),
829                None => json!({ "isNull": true }),
830            }
831        }
832    }
833}
834
835async fn my_connect(conn: &DbConn) -> Result<mysql_async::Conn, AwsServiceError> {
836    use mysql_async::OptsBuilder;
837    let opts = OptsBuilder::default()
838        .ip_or_hostname(conn.host.as_str())
839        .tcp_port(conn.port)
840        .user(Some(&conn.user))
841        .pass(Some(&conn.password))
842        .db_name(Some(&conn.db_name));
843    mysql_async::Conn::new(opts)
844        .await
845        .map_err(|e| bad_request(format!("could not connect to database: {e}")))
846}
847
848async fn my_run(
849    c: &mut mysql_async::Conn,
850    sql: &str,
851    params: &[(String, SqlValue)],
852    include_metadata: bool,
853    format_json: bool,
854) -> Result<Value, AwsServiceError> {
855    use mysql_async::prelude::Queryable;
856    use mysql_async::{Column, Row};
857
858    let stmt = inline_params(sql, params, mysql_literal);
859    let mut out = Map::new();
860
861    // Route writes through `query_drop` so the connection retains the INSERT's
862    // OK packet (and thus `last_insert_id`); `query` collecting rows can leave a
863    // result-set terminator as the last packet.
864    if !returns_rows(&stmt) {
865        c.query_drop(stmt.as_str())
866            .await
867            .map_err(|e| bad_request(format!("{e}")))?;
868        out.insert("numberOfRecordsUpdated".into(), json!(c.affected_rows()));
869        // AWS Aurora MySQL surfaces the auto-increment key in `generatedFields`
870        // (Postgres needs `RETURNING` instead, so its arm omits this). Read it
871        // back explicitly on the same connection — robust across the text
872        // protocol regardless of which OK packet the driver retained.
873        let generated: Option<u64> = c
874            .query_first("SELECT LAST_INSERT_ID()")
875            .await
876            .ok()
877            .flatten()
878            .filter(|id: &u64| *id != 0);
879        if let Some(id) = generated {
880            out.insert(
881                "generatedFields".into(),
882                json!([{ "longValue": id as i64 }]),
883            );
884        }
885        out.insert("records".into(), json!([]));
886        return Ok(Value::Object(out));
887    }
888
889    let rows: Vec<Row> = c
890        .query(stmt.as_str())
891        .await
892        .map_err(|e| bad_request(format!("{e}")))?;
893    let affected = c.affected_rows();
894
895    if rows.is_empty() {
896        out.insert("numberOfRecordsUpdated".into(), json!(affected));
897        if format_json {
898            out.insert("formattedRecords".into(), json!("[]"));
899        } else {
900            out.insert("records".into(), json!([]));
901        }
902        return Ok(Value::Object(out));
903    }
904
905    let cols: std::sync::Arc<[Column]> = rows[0].columns();
906    if include_metadata {
907        let md: Vec<Value> = cols
908            .iter()
909            .map(|c| {
910                json!({
911                    "name": c.name_str().to_string(),
912                    "label": c.name_str().to_string(),
913                    "typeName": format!("{:?}", c.column_type()),
914                    "nullable": 2,
915                })
916            })
917            .collect();
918        out.insert("columnMetadata".into(), Value::Array(md));
919    }
920
921    let mut records: Vec<Value> = Vec::with_capacity(rows.len());
922    let mut json_rows: Vec<Map<String, Value>> = Vec::new();
923    for row in &rows {
924        let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
925        let mut jr = Map::new();
926        for (i, col) in cols.iter().enumerate() {
927            let field = my_field(row, i, col.column_type());
928            if format_json {
929                jr.insert(col.name_str().to_string(), field_to_plain(&field));
930            }
931            rec.push(field);
932        }
933        records.push(Value::Array(rec));
934        if format_json {
935            json_rows.push(jr);
936        }
937    }
938    if format_json {
939        out.insert(
940            "formattedRecords".into(),
941            json!(serde_json::to_string(&json_rows).unwrap_or_default()),
942        );
943    } else {
944        out.insert("records".into(), Value::Array(records));
945    }
946    Ok(Value::Object(out))
947}
948
949fn my_field(row: &mysql_async::Row, i: usize, ty: mysql_async::consts::ColumnType) -> Value {
950    use mysql_async::Value as V;
951    match row.as_ref(i) {
952        Some(V::NULL) | None => json!({ "isNull": true }),
953        // Binary-protocol typed values (kept for completeness).
954        Some(V::Int(n)) => json!({ "longValue": n }),
955        Some(V::UInt(n)) => json!({ "longValue": *n as i64 }),
956        Some(V::Float(f)) => json!({ "doubleValue": *f as f64 }),
957        Some(V::Double(d)) => json!({ "doubleValue": d }),
958        // Under the text protocol every column arrives as raw bytes, so coerce
959        // numeric columns to the right typed Field by the column's declared
960        // type rather than always returning `stringValue`.
961        Some(V::Bytes(b)) => my_bytes_field(b, ty),
962        // Temporal columns arrive as typed Date/Time values under the binary
963        // protocol; render the canonical SQL text instead of the debug form.
964        Some(V::Date(y, mo, d, h, mi, s, us)) => {
965            json!({ "stringValue": format_mysql_datetime(*y, *mo, *d, *h, *mi, *s, *us) })
966        }
967        Some(V::Time(neg, days, h, mi, s, us)) => {
968            json!({ "stringValue": format_mysql_time(*neg, *days, *h, *mi, *s, *us) })
969        }
970    }
971}
972
973/// Coerce a text-protocol byte column to a typed AWS Field by its MySQL type.
974/// Integers -> `longValue`, real/double -> `doubleValue`; decimals, dates,
975/// times, and strings stay `stringValue`; non-UTF-8 bytes (true binary/blob)
976/// become `blobValue`.
977fn my_bytes_field(b: &[u8], ty: mysql_async::consts::ColumnType) -> Value {
978    use mysql_async::consts::ColumnType::*;
979    let as_str = std::str::from_utf8(b).ok();
980    match ty {
981        MYSQL_TYPE_TINY | MYSQL_TYPE_SHORT | MYSQL_TYPE_LONG | MYSQL_TYPE_LONGLONG
982        | MYSQL_TYPE_INT24 | MYSQL_TYPE_YEAR => {
983            if let Some(n) = as_str.and_then(|s| s.parse::<i64>().ok()) {
984                return json!({ "longValue": n });
985            }
986        }
987        MYSQL_TYPE_FLOAT | MYSQL_TYPE_DOUBLE => {
988            if let Some(d) = as_str.and_then(|s| s.parse::<f64>().ok()) {
989                return json!({ "doubleValue": d });
990            }
991        }
992        _ => {}
993    }
994    match as_str {
995        Some(s) => json!({ "stringValue": s }),
996        None => json!({ "blobValue": b64_encode(b) }),
997    }
998}
999
1000/// Render a MySQL `DATE`/`DATETIME`/`TIMESTAMP` value as canonical SQL text,
1001/// dropping the time portion for a pure date.
1002#[allow(clippy::too_many_arguments)]
1003fn format_mysql_datetime(
1004    year: u16,
1005    month: u8,
1006    day: u8,
1007    hour: u8,
1008    min: u8,
1009    sec: u8,
1010    micros: u32,
1011) -> String {
1012    let date = format!("{year:04}-{month:02}-{day:02}");
1013    if hour == 0 && min == 0 && sec == 0 && micros == 0 {
1014        return date;
1015    }
1016    if micros == 0 {
1017        format!("{date} {hour:02}:{min:02}:{sec:02}")
1018    } else {
1019        format!("{date} {hour:02}:{min:02}:{sec:02}.{micros:06}")
1020    }
1021}
1022
1023/// Render a MySQL `TIME` value (a signed duration) as canonical SQL text.
1024fn format_mysql_time(neg: bool, days: u32, hours: u8, mins: u8, secs: u8, micros: u32) -> String {
1025    let sign = if neg { "-" } else { "" };
1026    let total_hours = days * 24 + hours as u32;
1027    if micros == 0 {
1028        format!("{sign}{total_hours:02}:{mins:02}:{secs:02}")
1029    } else {
1030        format!("{sign}{total_hours:02}:{mins:02}:{secs:02}.{micros:06}")
1031    }
1032}
1033
1034/// Collapse a typed Field to a plain JSON scalar for `formatRecordsAs=JSON`.
1035fn field_to_plain(field: &Value) -> Value {
1036    let o = field.as_object();
1037    if o.and_then(|m| m.get("isNull")).is_some() {
1038        return Value::Null;
1039    }
1040    for k in [
1041        "stringValue",
1042        "longValue",
1043        "doubleValue",
1044        "booleanValue",
1045        "blobValue",
1046    ] {
1047        if let Some(v) = o.and_then(|m| m.get(k)) {
1048            return v.clone();
1049        }
1050    }
1051    Value::Null
1052}
1053
1054fn b64_encode(b: &[u8]) -> String {
1055    use base64::Engine;
1056    base64::engine::general_purpose::STANDARD.encode(b)
1057}
1058fn b64_decode(s: &str) -> Vec<u8> {
1059    use base64::Engine;
1060    base64::engine::general_purpose::STANDARD
1061        .decode(s)
1062        .unwrap_or_default()
1063}