use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use async_trait::async_trait;
use http::{Method, StatusCode};
use serde_json::{json, Map, Value};
use tokio::sync::Mutex;
use tokio::time::{Duration, Instant};
use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
use fakecloud_rds::SharedRdsState;
const SUPPORTED_ACTIONS: &[&str] = &[
"ExecuteStatement",
"BatchExecuteStatement",
"BeginTransaction",
"CommitTransaction",
"RollbackTransaction",
"ExecuteSql",
];
const TXN_IDLE_TIMEOUT: Duration = Duration::from_secs(180);
struct DbConn {
engine: String,
host: String,
port: u16,
user: String,
password: String,
db_name: String,
schema: Option<String>,
}
enum HeldConn {
Pg(tokio_postgres::Client),
MySql(mysql_async::Conn),
}
struct TxnEntry {
conn: Mutex<Option<HeldConn>>,
last_used: StdMutex<Instant>,
}
impl TxnEntry {
fn touch(&self) {
if let Ok(mut t) = self.last_used.lock() {
*t = Instant::now();
}
}
}
type TxnMap = Arc<Mutex<HashMap<String, Arc<TxnEntry>>>>;
pub struct RdsDataService {
rds_state: SharedRdsState,
transactions: TxnMap,
}
impl RdsDataService {
pub fn new(rds_state: SharedRdsState) -> Self {
let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(reap_idle_transactions(transactions.clone()));
}
Self {
rds_state,
transactions,
}
}
fn resolve_action(req: &AwsRequest) -> Option<&'static str> {
if req.method != Method::POST {
return None;
}
match req.path_segments.first().map(String::as_str)? {
"Execute" => Some("ExecuteStatement"),
"BatchExecute" => Some("BatchExecuteStatement"),
"BeginTransaction" => Some("BeginTransaction"),
"CommitTransaction" => Some("CommitTransaction"),
"RollbackTransaction" => Some("RollbackTransaction"),
"ExecuteSql" => Some("ExecuteSql"),
_ => None,
}
}
fn resolve_conn(&self, account_id: &str, resource_arn: &str) -> Option<DbConn> {
let cluster_id = resource_arn
.rsplit_once(":cluster:")
.map(|(_, id)| id.to_string());
let accounts = self.rds_state.read();
let state = accounts.get(account_id)?;
let inst = state.instances.values().find(|i| {
i.db_instance_arn == resource_arn
|| cluster_id
.as_deref()
.is_some_and(|c| i.db_cluster_identifier.as_deref() == Some(c))
})?;
Some(DbConn {
engine: inst.engine.clone(),
host: inst.endpoint_address.clone(),
port: inst.host_port,
user: inst.master_username.clone(),
password: inst.master_user_password.clone(),
db_name: inst.db_name.clone().unwrap_or_default(),
schema: None,
})
}
fn require_conn(&self, req: &AwsRequest, body: &Value) -> Result<DbConn, AwsServiceError> {
let resource_arn = body
.get("resourceArn")
.and_then(Value::as_str)
.ok_or_else(|| bad_request("resourceArn is required"))?;
if body.get("secretArn").and_then(Value::as_str).is_none() {
return Err(bad_request("secretArn is required"));
}
let mut conn = self
.resolve_conn(&req.account_id, resource_arn)
.ok_or_else(|| {
bad_request(format!(
"HttpEndpoint is not enabled for resource {resource_arn} (no such DB)"
))
})?;
if let Some(db) = body.get("database").and_then(Value::as_str) {
if !db.is_empty() {
conn.db_name = db.to_string();
}
}
conn.schema = body
.get("schema")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
Ok(conn)
}
async fn execute_statement(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
let body = req.json_body();
let sql = body
.get("sql")
.and_then(Value::as_str)
.ok_or_else(|| bad_request("sql is required"))?;
let include_metadata = body
.get("includeResultMetadata")
.and_then(Value::as_bool)
.unwrap_or(false);
let format_json = body
.get("formatRecordsAs")
.and_then(Value::as_str)
.map(|f| f == "JSON")
.unwrap_or(false);
let params = parse_parameters(body.get("parameters"));
if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
self.require_conn(req, &body)?;
let entry = self.txn_entry(txid).await?;
entry.touch();
let mut guard = entry.conn.lock().await;
let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
let value = match held {
HeldConn::Pg(client) => {
pg_run(client, sql, ¶ms, include_metadata, format_json).await?
}
HeldConn::MySql(conn) => {
my_run(conn, sql, ¶ms, include_metadata, format_json).await?
}
};
return Ok(AwsResponse::ok_json(value));
}
let conn = self.require_conn(req, &body)?;
let engine = conn.engine.to_lowercase();
let value = if engine.contains("postgres") {
let client = pg_connect(&conn).await?;
pg_run(&client, sql, ¶ms, include_metadata, format_json).await?
} else if is_mysql(&engine) {
let mut c = my_connect(&conn).await?;
let v = my_run(&mut c, sql, ¶ms, include_metadata, format_json).await;
let _ = c.disconnect().await;
v?
} else {
return Err(unsupported_engine(&conn.engine));
};
Ok(AwsResponse::ok_json(value))
}
async fn begin_transaction(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
let body = req.json_body();
let conn = self.require_conn(req, &body)?;
let engine = conn.engine.to_lowercase();
let held = if engine.contains("postgres") {
let client = pg_connect(&conn).await?;
client
.batch_execute("BEGIN")
.await
.map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
HeldConn::Pg(client)
} else if is_mysql(&engine) {
use mysql_async::prelude::Queryable;
let mut c = my_connect(&conn).await?;
c.query_drop("START TRANSACTION")
.await
.map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
HeldConn::MySql(c)
} else {
return Err(unsupported_engine(&conn.engine));
};
let txid = gen_transaction_id();
let entry = Arc::new(TxnEntry {
conn: Mutex::new(Some(held)),
last_used: StdMutex::new(Instant::now()),
});
self.transactions.lock().await.insert(txid.clone(), entry);
Ok(AwsResponse::ok_json(json!({ "transactionId": txid })))
}
async fn txn_entry(&self, txid: &str) -> Result<Arc<TxnEntry>, AwsServiceError> {
self.transactions
.lock()
.await
.get(txid)
.cloned()
.ok_or_else(|| not_found_txn(txid))
}
async fn finish_transaction(
&self,
req: &AwsRequest,
sql_keyword: &str,
status: &str,
) -> Result<AwsResponse, AwsServiceError> {
let body = req.json_body();
let txid = body
.get("transactionId")
.and_then(Value::as_str)
.ok_or_else(|| bad_request("transactionId is required"))?;
let entry = self
.transactions
.lock()
.await
.remove(txid)
.ok_or_else(|| not_found_txn(txid))?;
let held = entry
.conn
.lock()
.await
.take()
.ok_or_else(|| not_found_txn(txid))?;
finish_held(held, sql_keyword).await?;
Ok(AwsResponse::ok_json(json!({ "transactionStatus": status })))
}
async fn batch_execute_statement(
&self,
req: &AwsRequest,
) -> Result<AwsResponse, AwsServiceError> {
let body = req.json_body();
let sql = body
.get("sql")
.and_then(Value::as_str)
.ok_or_else(|| bad_request("sql is required"))?;
let sets = parse_parameter_sets(body.get("parameterSets"));
if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
self.require_conn(req, &body)?;
let entry = self.txn_entry(txid).await?;
entry.touch();
let mut guard = entry.conn.lock().await;
let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
let mut results = Vec::with_capacity(sets.len().max(1));
match held {
HeldConn::Pg(client) => {
for set in run_each(&sets) {
pg_run(client, sql, set, false, false).await?;
results.push(json!({ "generatedFields": [] }));
}
}
HeldConn::MySql(conn) => {
for set in run_each(&sets) {
my_run(conn, sql, set, false, false).await?;
results.push(json!({ "generatedFields": [] }));
}
}
}
return Ok(AwsResponse::ok_json(json!({ "updateResults": results })));
}
let conn = self.require_conn(req, &body)?;
let engine = conn.engine.to_lowercase();
let mut results = Vec::with_capacity(sets.len().max(1));
if engine.contains("postgres") {
let client = pg_connect(&conn).await?;
for set in run_each(&sets) {
pg_run(&client, sql, set, false, false).await?;
results.push(json!({ "generatedFields": [] }));
}
} else if is_mysql(&engine) {
let mut c = my_connect(&conn).await?;
for set in run_each(&sets) {
if let Err(e) = my_run(&mut c, sql, set, false, false).await {
let _ = c.disconnect().await;
return Err(e);
}
results.push(json!({ "generatedFields": [] }));
}
let _ = c.disconnect().await;
} else {
return Err(unsupported_engine(&conn.engine));
}
Ok(AwsResponse::ok_json(json!({ "updateResults": results })))
}
}
#[async_trait]
impl AwsService for RdsDataService {
fn service_name(&self) -> &str {
"rds-data"
}
fn supported_actions(&self) -> &[&str] {
SUPPORTED_ACTIONS
}
async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
let Some(action) = Self::resolve_action(&req) else {
return Err(AwsServiceError::aws_error(
StatusCode::NOT_FOUND,
"ResourceNotFoundException",
format!("Unknown operation: {} {}", req.method, req.raw_path),
));
};
match action {
"ExecuteStatement" => self.execute_statement(&req).await,
"BatchExecuteStatement" => self.batch_execute_statement(&req).await,
"BeginTransaction" => self.begin_transaction(&req).await,
"CommitTransaction" => {
self.finish_transaction(&req, "COMMIT", "Transaction Committed")
.await
}
"RollbackTransaction" => {
self.finish_transaction(&req, "ROLLBACK", "Rollback Complete")
.await
}
other => Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"BadRequestException",
format!("rds-data operation {other} is not supported"),
)),
}
}
}
fn bad_request(msg: impl Into<String>) -> AwsServiceError {
AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg.into())
}
fn not_found_txn(txid: &str) -> AwsServiceError {
AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"BadRequestException",
format!("Transaction {txid} is not found"),
)
}
fn unsupported_engine(engine: &str) -> AwsServiceError {
bad_request(format!("RDS Data API is not supported for engine {engine}"))
}
fn is_mysql(engine_lower: &str) -> bool {
engine_lower.contains("mysql") || engine_lower.contains("maria")
}
fn gen_transaction_id() -> String {
use rand::RngCore;
let mut bytes = [0u8; 48];
rand::thread_rng().fill_bytes(&mut bytes);
b64_encode(&bytes)
}
async fn finish_held(held: HeldConn, sql_keyword: &str) -> Result<(), AwsServiceError> {
match held {
HeldConn::Pg(client) => {
client
.batch_execute(sql_keyword)
.await
.map_err(|e| bad_request(format!("{e}")))?;
}
HeldConn::MySql(mut conn) => {
use mysql_async::prelude::Queryable;
conn.query_drop(sql_keyword)
.await
.map_err(|e| bad_request(format!("{e}")))?;
let _ = conn.disconnect().await;
}
}
Ok(())
}
async fn reap_idle_transactions(transactions: TxnMap) {
let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
tick.tick().await;
let now = Instant::now();
let stale: Vec<(String, Arc<TxnEntry>)> = {
let map = transactions.lock().await;
map.iter()
.filter(|(_, e)| {
e.last_used
.lock()
.map(|t| now.duration_since(*t) > TXN_IDLE_TIMEOUT)
.unwrap_or(false)
})
.map(|(id, e)| (id.clone(), e.clone()))
.collect()
};
for (id, entry) in stale {
transactions.lock().await.remove(&id);
if let Some(held) = entry.conn.lock().await.take() {
let _ = finish_held(held, "ROLLBACK").await;
}
}
}
}
enum SqlValue {
Null,
Bool(bool),
Long(i64),
Double(f64),
Text(String),
Blob(Vec<u8>),
}
type Params = Vec<(String, SqlValue)>;
fn parse_parameters(params: Option<&Value>) -> Params {
let Some(arr) = params.and_then(Value::as_array) else {
return Vec::new();
};
arr.iter()
.filter_map(|p| {
let name = p.get("name").and_then(Value::as_str)?.to_string();
let v = p.get("value")?;
let sv = if v.get("isNull").and_then(Value::as_bool) == Some(true) {
SqlValue::Null
} else if let Some(b) = v.get("booleanValue").and_then(Value::as_bool) {
SqlValue::Bool(b)
} else if let Some(n) = v.get("longValue").and_then(Value::as_i64) {
SqlValue::Long(n)
} else if let Some(d) = v.get("doubleValue").and_then(Value::as_f64) {
SqlValue::Double(d)
} else if let Some(s) = v.get("stringValue").and_then(Value::as_str) {
SqlValue::Text(s.to_string())
} else if let Some(b) = v.get("blobValue").and_then(Value::as_str) {
SqlValue::Blob(b64_decode(b))
} else {
SqlValue::Null
};
Some((name, sv))
})
.collect()
}
fn parse_parameter_sets(sets: Option<&Value>) -> Vec<Params> {
let Some(arr) = sets.and_then(Value::as_array) else {
return Vec::new();
};
arr.iter().map(|s| parse_parameters(Some(s))).collect()
}
fn run_each(sets: &[Params]) -> Vec<&[(String, SqlValue)]> {
if sets.is_empty() {
vec![&[]]
} else {
sets.iter().map(Vec::as_slice).collect()
}
}
fn inline_params(
sql: &str,
params: &[(String, SqlValue)],
lit: impl Fn(&SqlValue) -> String,
) -> String {
let mut out = String::with_capacity(sql.len());
let bytes = sql.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
let mut j = i + 1;
while j < bytes.len() && is_ident_char(bytes[j]) {
j += 1;
}
let name = &sql[i + 1..j];
if let Some((_, v)) = params.iter().find(|(n, _)| n == name) {
out.push_str(&lit(v));
i = j;
continue;
}
}
let ch = sql[i..]
.chars()
.next()
.expect("byte index is on a char boundary");
out.push(ch);
i += ch.len_utf8();
}
out
}
fn sql_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn quote_ident(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
fn pg_literal(v: &SqlValue) -> String {
match v {
SqlValue::Null => "NULL".to_string(),
SqlValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
SqlValue::Long(n) => n.to_string(),
SqlValue::Double(d) => d.to_string(),
SqlValue::Text(s) => sql_quote(s),
SqlValue::Blob(b) => format!("'\\x{}'::bytea", hex(b)),
}
}
fn mysql_literal(v: &SqlValue) -> String {
match v {
SqlValue::Null => "NULL".to_string(),
SqlValue::Bool(b) => if *b { "1" } else { "0" }.to_string(),
SqlValue::Long(n) => n.to_string(),
SqlValue::Double(d) => d.to_string(),
SqlValue::Text(s) => sql_quote(s),
SqlValue::Blob(b) => format!("x'{}'", hex(b)),
}
}
fn returns_rows(sql: &str) -> bool {
let head = sql.trim_start().to_ascii_uppercase();
head.starts_with("SELECT")
|| head.starts_with("WITH")
|| head.starts_with("SHOW")
|| head.starts_with("VALUES")
|| head.starts_with("TABLE")
|| head.starts_with("EXPLAIN")
|| head.contains(" RETURNING ")
}
fn hex(b: &[u8]) -> String {
let mut s = String::with_capacity(b.len() * 2);
for byte in b {
s.push_str(&format!("{byte:02x}"));
}
s
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
async fn pg_connect(conn: &DbConn) -> Result<tokio_postgres::Client, AwsServiceError> {
use tokio_postgres::NoTls;
let cs = format!(
"host={} port={} user={} password={} dbname={}",
conn.host, conn.port, conn.user, conn.password, conn.db_name
);
let (client, connection) = tokio_postgres::connect(&cs, NoTls)
.await
.map_err(|e| bad_request(format!("could not connect to database: {e}")))?;
tokio::spawn(async move {
let _ = connection.await;
});
if let Some(schema) = &conn.schema {
client
.batch_execute(&format!("SET search_path TO {}", quote_ident(schema)))
.await
.map_err(|e| bad_request(format!("could not set schema: {e}")))?;
}
Ok(client)
}
async fn pg_run(
client: &tokio_postgres::Client,
sql: &str,
params: &[(String, SqlValue)],
include_metadata: bool,
format_json: bool,
) -> Result<Value, AwsServiceError> {
let stmt = inline_params(sql, params, pg_literal);
let no_params: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[];
let mut out = Map::new();
if !returns_rows(&stmt) {
let affected = client
.execute(stmt.as_str(), no_params)
.await
.map_err(|e| bad_request(format!("{e}")))?;
out.insert("numberOfRecordsUpdated".into(), json!(affected));
out.insert("records".into(), json!([]));
return Ok(Value::Object(out));
}
let rows = client
.query(stmt.as_str(), no_params)
.await
.map_err(|e| bad_request(format!("{e}")))?;
if rows.is_empty() {
out.insert("numberOfRecordsUpdated".into(), json!(0));
if format_json {
out.insert("formattedRecords".into(), json!("[]"));
} else {
out.insert("records".into(), json!([]));
}
return Ok(Value::Object(out));
}
let cols = rows[0].columns();
if include_metadata {
let md: Vec<Value> = cols
.iter()
.map(|c| {
json!({
"name": c.name(),
"label": c.name(),
"typeName": c.type_().name(),
"nullable": 2, })
})
.collect();
out.insert("columnMetadata".into(), Value::Array(md));
}
let mut records: Vec<Value> = Vec::with_capacity(rows.len());
let mut json_rows: Vec<Map<String, Value>> = Vec::new();
for row in &rows {
let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
let mut jr = Map::new();
for (i, col) in cols.iter().enumerate() {
let field = pg_field(row, i, col.type_());
if format_json {
jr.insert(col.name().to_string(), field_to_plain(&field));
}
rec.push(field);
}
records.push(Value::Array(rec));
if format_json {
json_rows.push(jr);
}
}
if format_json {
out.insert(
"formattedRecords".into(),
json!(serde_json::to_string(&json_rows).unwrap_or_default()),
);
} else {
out.insert("records".into(), Value::Array(records));
}
Ok(Value::Object(out))
}
fn pg_timestamp_text(base: impl std::fmt::Display, micros: u32) -> String {
if micros == 0 {
return base.to_string();
}
let frac = format!("{micros:06}");
let frac = frac.trim_end_matches('0');
format!("{base}.{frac}")
}
fn pg_field(row: &tokio_postgres::Row, i: usize, ty: &tokio_postgres::types::Type) -> Value {
use tokio_postgres::types::Type;
macro_rules! opt {
($t:ty, $key:expr, $conv:expr) => {{
let v: Option<$t> = row.try_get(i).unwrap_or(None);
match v {
Some(x) => json!({ $key: $conv(x) }),
None => json!({ "isNull": true }),
}
}};
}
macro_rules! strv {
($t:ty, $conv:expr) => {{
let v: Option<$t> = row.try_get(i).unwrap_or(None);
match v {
Some(x) => json!({ "stringValue": $conv(x) }),
None => json!({ "isNull": true }),
}
}};
}
match *ty {
Type::BOOL => opt!(bool, "booleanValue", |x| x),
Type::INT2 => opt!(i16, "longValue", |x| x as i64),
Type::INT4 => opt!(i32, "longValue", |x| x as i64),
Type::INT8 => opt!(i64, "longValue", |x: i64| x),
Type::FLOAT4 => opt!(f32, "doubleValue", |x| x as f64),
Type::FLOAT8 => opt!(f64, "doubleValue", |x: f64| x),
Type::NUMERIC => strv!(rust_decimal::Decimal, |d: rust_decimal::Decimal| d
.to_string()),
Type::UUID => strv!(uuid::Uuid, |u: uuid::Uuid| u.to_string()),
Type::JSON | Type::JSONB => {
strv!(serde_json::Value, |j: serde_json::Value| j.to_string())
}
Type::TIMESTAMP => strv!(chrono::NaiveDateTime, |t: chrono::NaiveDateTime| {
pg_timestamp_text(
t.format("%Y-%m-%d %H:%M:%S"),
t.and_utc().timestamp_subsec_micros(),
)
}),
Type::TIMESTAMPTZ => strv!(chrono::DateTime<chrono::Utc>, |t: chrono::DateTime<
chrono::Utc,
>| format!(
"{}{}",
pg_timestamp_text(t.format("%Y-%m-%d %H:%M:%S"), t.timestamp_subsec_micros()),
t.format("%:z")
)),
Type::DATE => strv!(chrono::NaiveDate, |d: chrono::NaiveDate| d.to_string()),
Type::TIME => strv!(chrono::NaiveTime, |t: chrono::NaiveTime| t.to_string()),
Type::BYTEA => {
let v: Option<Vec<u8>> = row.try_get(i).unwrap_or(None);
match v {
Some(b) => json!({ "blobValue": b64_encode(&b) }),
None => json!({ "isNull": true }),
}
}
_ => {
let v: Option<String> = row.try_get(i).unwrap_or(None);
match v {
Some(s) => json!({ "stringValue": s }),
None => json!({ "isNull": true }),
}
}
}
}
async fn my_connect(conn: &DbConn) -> Result<mysql_async::Conn, AwsServiceError> {
use mysql_async::OptsBuilder;
let opts = OptsBuilder::default()
.ip_or_hostname(conn.host.as_str())
.tcp_port(conn.port)
.user(Some(&conn.user))
.pass(Some(&conn.password))
.db_name(Some(&conn.db_name));
mysql_async::Conn::new(opts)
.await
.map_err(|e| bad_request(format!("could not connect to database: {e}")))
}
async fn my_run(
c: &mut mysql_async::Conn,
sql: &str,
params: &[(String, SqlValue)],
include_metadata: bool,
format_json: bool,
) -> Result<Value, AwsServiceError> {
use mysql_async::prelude::Queryable;
use mysql_async::{Column, Row};
let stmt = inline_params(sql, params, mysql_literal);
let mut out = Map::new();
if !returns_rows(&stmt) {
c.query_drop(stmt.as_str())
.await
.map_err(|e| bad_request(format!("{e}")))?;
out.insert("numberOfRecordsUpdated".into(), json!(c.affected_rows()));
let generated: Option<u64> = c
.query_first("SELECT LAST_INSERT_ID()")
.await
.ok()
.flatten()
.filter(|id: &u64| *id != 0);
if let Some(id) = generated {
out.insert(
"generatedFields".into(),
json!([{ "longValue": id as i64 }]),
);
}
out.insert("records".into(), json!([]));
return Ok(Value::Object(out));
}
let rows: Vec<Row> = c
.query(stmt.as_str())
.await
.map_err(|e| bad_request(format!("{e}")))?;
let affected = c.affected_rows();
if rows.is_empty() {
out.insert("numberOfRecordsUpdated".into(), json!(affected));
if format_json {
out.insert("formattedRecords".into(), json!("[]"));
} else {
out.insert("records".into(), json!([]));
}
return Ok(Value::Object(out));
}
let cols: std::sync::Arc<[Column]> = rows[0].columns();
if include_metadata {
let md: Vec<Value> = cols
.iter()
.map(|c| {
json!({
"name": c.name_str().to_string(),
"label": c.name_str().to_string(),
"typeName": format!("{:?}", c.column_type()),
"nullable": 2,
})
})
.collect();
out.insert("columnMetadata".into(), Value::Array(md));
}
let mut records: Vec<Value> = Vec::with_capacity(rows.len());
let mut json_rows: Vec<Map<String, Value>> = Vec::new();
for row in &rows {
let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
let mut jr = Map::new();
for (i, col) in cols.iter().enumerate() {
let field = my_field(row, i, col.column_type());
if format_json {
jr.insert(col.name_str().to_string(), field_to_plain(&field));
}
rec.push(field);
}
records.push(Value::Array(rec));
if format_json {
json_rows.push(jr);
}
}
if format_json {
out.insert(
"formattedRecords".into(),
json!(serde_json::to_string(&json_rows).unwrap_or_default()),
);
} else {
out.insert("records".into(), Value::Array(records));
}
Ok(Value::Object(out))
}
fn my_field(row: &mysql_async::Row, i: usize, ty: mysql_async::consts::ColumnType) -> Value {
use mysql_async::Value as V;
match row.as_ref(i) {
Some(V::NULL) | None => json!({ "isNull": true }),
Some(V::Int(n)) => json!({ "longValue": n }),
Some(V::UInt(n)) => json!({ "longValue": *n as i64 }),
Some(V::Float(f)) => json!({ "doubleValue": *f as f64 }),
Some(V::Double(d)) => json!({ "doubleValue": d }),
Some(V::Bytes(b)) => my_bytes_field(b, ty),
Some(V::Date(y, mo, d, h, mi, s, us)) => {
json!({ "stringValue": format_mysql_datetime(*y, *mo, *d, *h, *mi, *s, *us) })
}
Some(V::Time(neg, days, h, mi, s, us)) => {
json!({ "stringValue": format_mysql_time(*neg, *days, *h, *mi, *s, *us) })
}
}
}
fn my_bytes_field(b: &[u8], ty: mysql_async::consts::ColumnType) -> Value {
use mysql_async::consts::ColumnType::*;
let as_str = std::str::from_utf8(b).ok();
match ty {
MYSQL_TYPE_TINY | MYSQL_TYPE_SHORT | MYSQL_TYPE_LONG | MYSQL_TYPE_LONGLONG
| MYSQL_TYPE_INT24 | MYSQL_TYPE_YEAR => {
if let Some(n) = as_str.and_then(|s| s.parse::<i64>().ok()) {
return json!({ "longValue": n });
}
}
MYSQL_TYPE_FLOAT | MYSQL_TYPE_DOUBLE => {
if let Some(d) = as_str.and_then(|s| s.parse::<f64>().ok()) {
return json!({ "doubleValue": d });
}
}
_ => {}
}
match as_str {
Some(s) => json!({ "stringValue": s }),
None => json!({ "blobValue": b64_encode(b) }),
}
}
#[allow(clippy::too_many_arguments)]
fn format_mysql_datetime(
year: u16,
month: u8,
day: u8,
hour: u8,
min: u8,
sec: u8,
micros: u32,
) -> String {
let date = format!("{year:04}-{month:02}-{day:02}");
if hour == 0 && min == 0 && sec == 0 && micros == 0 {
return date;
}
if micros == 0 {
format!("{date} {hour:02}:{min:02}:{sec:02}")
} else {
format!("{date} {hour:02}:{min:02}:{sec:02}.{micros:06}")
}
}
fn format_mysql_time(neg: bool, days: u32, hours: u8, mins: u8, secs: u8, micros: u32) -> String {
let sign = if neg { "-" } else { "" };
let total_hours = days * 24 + hours as u32;
if micros == 0 {
format!("{sign}{total_hours:02}:{mins:02}:{secs:02}")
} else {
format!("{sign}{total_hours:02}:{mins:02}:{secs:02}.{micros:06}")
}
}
fn field_to_plain(field: &Value) -> Value {
let o = field.as_object();
if o.and_then(|m| m.get("isNull")).is_some() {
return Value::Null;
}
for k in [
"stringValue",
"longValue",
"doubleValue",
"booleanValue",
"blobValue",
] {
if let Some(v) = o.and_then(|m| m.get(k)) {
return v.clone();
}
}
Value::Null
}
fn b64_encode(b: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(b)
}
fn b64_decode(s: &str) -> Vec<u8> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(s)
.unwrap_or_default()
}