use crate::foundation::DbError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CopyFormat {
Text,
}
#[derive(Debug, Clone)]
pub struct CopyStatement {
table: String,
columns: Vec<String>,
format: CopyFormat,
}
impl CopyStatement {
pub fn new(table: &str, columns: &[String]) -> Result<Self, DbError> {
validate_identifier(table).map_err(|_| {
DbError::Config(format!(
"COPY target table identifier is invalid: '{table}' \
(allowed: letters/digits/underscore/dot)"
))
})?;
let mut cols = Vec::with_capacity(columns.len());
for c in columns {
validate_identifier(c).map_err(|_| {
DbError::Config(format!(
"COPY column identifier is invalid: '{c}' \
(allowed: letters/digits/underscore/dot)"
))
})?;
cols.push(c.clone());
}
if cols.is_empty() {
return Err(DbError::Config(
"COPY requires at least one column (MVP contract)".to_string(),
));
}
Ok(Self {
table: table.to_string(),
columns: cols,
format: CopyFormat::Text,
})
}
pub fn format(&self) -> CopyFormat {
self.format
}
pub fn table(&self) -> &str {
&self.table
}
pub fn columns(&self) -> &[String] {
&self.columns
}
pub fn build(&self) -> String {
let cols = self
.columns
.iter()
.map(|c| quote_identifier(c))
.collect::<Vec<_>>()
.join(", ");
format!(
"COPY {} ({}) FROM STDIN",
quote_identifier(&self.table),
cols
)
}
}
fn validate_identifier(name: &str) -> Result<(), ()> {
if name.is_empty() {
return Err(());
}
let first = name.chars().next().unwrap();
let valid = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '.';
if !(first.is_ascii_alphabetic() || first == '_') || !name.chars().all(valid) {
return Err(());
}
if name.contains("..") || name.starts_with('.') || name.ends_with('.') {
return Err(());
}
Ok(())
}
fn quote_identifier(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
pub fn encode_copy_rows(rows: &[Vec<serde_json::Value>]) -> String {
let mut out = String::new();
for row in rows {
let fields: Vec<String> = row.iter().map(encode_copy_value).collect();
out.push_str(&fields.join("\t"));
out.push('\n');
}
out
}
fn encode_copy_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "\\N".to_string(),
serde_json::Value::String(s) => escape_copy_text(s),
other => escape_copy_text(&other.to_string()),
}
}
fn escape_copy_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
_ => out.push(c),
}
}
out
}
impl crate::database::DbPool {
pub async fn copy_in(
&self,
statement: &CopyStatement,
rows: &[Vec<serde_json::Value>],
) -> crate::foundation::DbResult<u64> {
#[cfg(feature = "postgres")]
{
use sea_orm::sqlx::postgres::PgPoolCopyExt;
if rows.is_empty() {
return Err(crate::foundation::DbError::Config(
"copy_in requires at least one row".to_string(),
));
}
let conn = self.acquire_connection().await?;
let outcome: crate::foundation::DbResult<u64> = async {
let sea_conn = conn.as_sea_orm()?;
let pg_pool = sea_conn.get_postgres_connection_pool();
let sql = statement.build();
let payload = encode_copy_rows(rows);
let mut copy_in = pg_pool.copy_in_raw(&sql).await.map_err(|e| {
crate::foundation::DbError::Connection(sea_orm::DbErr::Conn(
sea_orm::RuntimeErr::SqlxError(std::sync::Arc::new(e)),
))
})?;
copy_in.send(payload.into_bytes()).await.map_err(|e| {
crate::foundation::DbError::Connection(sea_orm::DbErr::Conn(
sea_orm::RuntimeErr::SqlxError(std::sync::Arc::new(e)),
))
})?;
copy_in.finish().await.map_err(|e| {
crate::foundation::DbError::Connection(sea_orm::DbErr::Conn(
sea_orm::RuntimeErr::SqlxError(std::sync::Arc::new(e)),
))
})
}
.await;
self.release_connection(conn);
return outcome;
}
#[cfg(not(feature = "postgres"))]
{
let _ = (statement, rows);
Err(crate::foundation::DbError::Query(
"copy_in supports the postgres backend only (COPY protocol is \
PostgreSQL-specific); enable the postgres driver feature. \
sqlite/mysql/duckdb must use the regular INSERT paths"
.to_string(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quote_identifier_escapes_quotes() {
assert_eq!(quote_identifier("a\"b"), "\"a\"\"b\"");
}
#[test]
fn test_validate_identifier() {
assert!(validate_identifier("t_users").is_ok());
assert!(validate_identifier("public.users").is_ok());
assert!(validate_identifier("_v2").is_ok());
assert!(validate_identifier("9t").is_err());
assert!(validate_identifier("t;drop").is_err());
assert!(validate_identifier("").is_err());
assert!(validate_identifier("t..x").is_err());
}
#[test]
fn test_encode_empty_rows() {
assert_eq!(encode_copy_rows(&[]), "");
}
#[test]
fn test_copy_statement_rejects_empty_columns() {
assert!(CopyStatement::new("t", &[]).is_err());
}
}