Skip to main content

spring_batch_rs/item/rdbc/
postgres_writer.rs

1use serde::Serialize;
2use sqlx::{Pool, Postgres, QueryBuilder};
3
4use crate::core::item::{ItemWriter, ItemWriterResult};
5use crate::item::rdbc::ColumnValue;
6
7use super::writer_common::{
8    bind_column_value, create_write_error, log_write_success, max_items_per_batch, validate_config,
9};
10
11/// A writer for inserting items into a PostgreSQL database using SQLx.
12///
13/// Supports batch INSERT via a list of column bindings supplied through
14/// [`RdbcItemWriterBuilder::column`](crate::item::rdbc::RdbcItemWriterBuilder::column).
15///
16/// # Construction
17///
18/// Use [`RdbcItemWriterBuilder`](crate::item::rdbc::RdbcItemWriterBuilder) — direct
19/// construction is not public.
20///
21/// # Examples
22///
23/// ```no_run
24/// use spring_batch_rs::item::rdbc::{RdbcItemWriterBuilder, ColumnValue};
25/// use sqlx::PgPool;
26/// use serde::Serialize;
27///
28/// #[derive(Clone, Serialize)]
29/// struct User { id: i32, name: String }
30///
31/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
32/// let pool = PgPool::connect("postgresql://user:pass@localhost/db").await?;
33///
34/// let writer = RdbcItemWriterBuilder::<User>::new()
35///     .postgres(&pool)
36///     .table("users")
37///     .column("id", |u: &User| u.id.into())
38///     .column("name", |u: &User| u.name.as_str().into())
39///     .build_postgres();
40/// # Ok(())
41/// # }
42/// ```
43pub struct PostgresItemWriter<O> {
44    pub(crate) pool: Option<sqlx::Pool<Postgres>>,
45    pub(crate) table: Option<String>,
46    #[allow(clippy::type_complexity)]
47    pub(crate) column_bindings: Vec<(String, Box<dyn Fn(&O) -> ColumnValue>)>,
48}
49
50impl<O> PostgresItemWriter<O> {
51    /// Creates a new `PostgresItemWriter` with default configuration.
52    pub(crate) fn new() -> Self {
53        Self {
54            pool: None,
55            table: None,
56            column_bindings: Vec::new(),
57        }
58    }
59
60    /// Sets the database connection pool for the writer.
61    pub(crate) fn pool(mut self, pool: &Pool<Postgres>) -> Self {
62        self.pool = Some(pool.clone());
63        self
64    }
65
66    /// Sets the table name for the writer.
67    pub(crate) fn table(mut self, table: &str) -> Self {
68        self.table = Some(table.to_string());
69        self
70    }
71
72    /// Adds a column binding to the writer.
73    pub(crate) fn add_column_binding(
74        mut self,
75        name: String,
76        extractor: Box<dyn Fn(&O) -> ColumnValue>,
77    ) -> Self {
78        self.column_bindings.push((name, extractor));
79        self
80    }
81}
82
83impl<O> Default for PostgresItemWriter<O> {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl<O: Serialize + Clone> ItemWriter<O> for PostgresItemWriter<O> {
90    fn write(&self, items: &[O]) -> ItemWriterResult {
91        if items.is_empty() {
92            return Ok(());
93        }
94
95        let (pool, table) = validate_config(
96            self.pool.as_ref(),
97            self.table.as_deref(),
98            self.column_bindings.len(),
99        )?;
100
101        let col_names: Vec<&str> = self
102            .column_bindings
103            .iter()
104            .map(|(n, _)| n.as_str())
105            .collect();
106
107        let col_list = col_names.join(",");
108        let max_items = max_items_per_batch(self.column_bindings.len());
109
110        for chunk in items.chunks(max_items) {
111            let mut query_builder = QueryBuilder::new("INSERT INTO ");
112            query_builder.push(table);
113            query_builder.push(" (");
114            query_builder.push(&col_list);
115            query_builder.push(") ");
116
117            query_builder.push_values(chunk.iter(), |mut b, item| {
118                for (_, extractor) in &self.column_bindings {
119                    bind_column_value!(b, extractor(item));
120                }
121            });
122
123            let query = query_builder.build();
124            let result = tokio::task::block_in_place(|| {
125                tokio::runtime::Handle::current().block_on(async { query.execute(pool).await })
126            });
127
128            if let Err(e) = result {
129                return Err(create_write_error(table, "PostgreSQL", e));
130            }
131        }
132
133        log_write_success(items.len(), table, "PostgreSQL");
134        Ok(())
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::item::rdbc::ColumnValue;
142
143    #[test]
144    fn should_start_with_empty_state() {
145        let writer = PostgresItemWriter::<String>::new();
146        assert!(writer.pool.is_none());
147        assert!(writer.table.is_none());
148        assert!(writer.column_bindings.is_empty());
149    }
150
151    #[test]
152    fn should_store_column_bindings_in_order() {
153        let writer = PostgresItemWriter::<String>::new()
154            .add_column_binding("x".to_string(), Box::new(|_| ColumnValue::Null))
155            .add_column_binding("y".to_string(), Box::new(|_| ColumnValue::Null));
156        let names: Vec<&str> = writer
157            .column_bindings
158            .iter()
159            .map(|(n, _)| n.as_str())
160            .collect();
161        assert_eq!(names, vec!["x", "y"]);
162    }
163
164    #[test]
165    fn should_return_ok_for_empty_items() {
166        let writer = PostgresItemWriter::<String>::new();
167        assert!(writer.write(&[]).is_ok());
168    }
169
170    #[test]
171    fn should_return_error_when_no_columns_and_items_given() {
172        use crate::BatchError;
173        let writer = PostgresItemWriter::<String>::new().table("t");
174        let result = writer.write(&["x".to_string()]);
175        match result.err().unwrap() {
176            BatchError::ItemWriter(msg) => assert!(msg.contains("columns"), "{msg}"),
177            e => panic!("expected ItemWriter, got {e:?}"),
178        }
179    }
180
181    #[test]
182    fn should_return_error_when_pool_not_configured() {
183        use crate::BatchError;
184        let writer = PostgresItemWriter::<String>::new()
185            .table("t")
186            .add_column_binding("v".to_string(), Box::new(|s: &String| s.as_str().into()));
187        let result = writer.write(&["x".to_string()]);
188        match result.err().unwrap() {
189            BatchError::ItemWriter(msg) => assert!(msg.contains("pool"), "{msg}"),
190            e => panic!("expected ItemWriter, got {e:?}"),
191        }
192    }
193}