Skip to main content

spring_batch_rs/item/rdbc/
sqlite_writer.rs

1use serde::Serialize;
2use sqlx::{Pool, QueryBuilder, Sqlite};
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 SQLite 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::SqlitePool;
26/// use serde::Serialize;
27///
28/// #[derive(Clone, Serialize)]
29/// struct Task { id: i32, title: String }
30///
31/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
32/// let pool = SqlitePool::connect("sqlite::memory:").await?;
33///
34/// let writer = RdbcItemWriterBuilder::<Task>::new()
35///     .sqlite(&pool)
36///     .table("tasks")
37///     .column("id", |t: &Task| t.id.into())
38///     .column("title", |t: &Task| t.title.as_str().into())
39///     .build_sqlite();
40/// # Ok(())
41/// # }
42/// ```
43pub struct SqliteItemWriter<O> {
44    pub(crate) pool: Option<sqlx::Pool<Sqlite>>,
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> SqliteItemWriter<O> {
51    /// Creates a new `SqliteItemWriter` 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<Sqlite>) -> 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 SqliteItemWriter<O> {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl<O: Serialize + Clone> ItemWriter<O> for SqliteItemWriter<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, "SQLite", e));
130            }
131        }
132
133        log_write_success(items.len(), table, "SQLite");
134        Ok(())
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::core::item::ItemWriter;
142    use crate::item::rdbc::ColumnValue;
143
144    #[test]
145    fn should_start_with_empty_state() {
146        let writer = SqliteItemWriter::<String>::new();
147        assert!(writer.pool.is_none());
148        assert!(writer.table.is_none());
149        assert!(writer.column_bindings.is_empty());
150    }
151
152    #[test]
153    fn should_store_column_bindings_in_order() {
154        let writer = SqliteItemWriter::<String>::new()
155            .table("t")
156            .add_column_binding("a".to_string(), Box::new(|_| ColumnValue::Null))
157            .add_column_binding("b".to_string(), Box::new(|_| ColumnValue::Null));
158        let names: Vec<&str> = writer
159            .column_bindings
160            .iter()
161            .map(|(n, _)| n.as_str())
162            .collect();
163        assert_eq!(
164            names,
165            vec!["a", "b"],
166            "bindings should preserve insertion order"
167        );
168    }
169
170    #[test]
171    fn should_return_ok_for_empty_items() {
172        let writer = SqliteItemWriter::<String>::new();
173        assert!(writer.write(&[]).is_ok());
174    }
175
176    #[test]
177    fn should_return_error_when_no_columns_and_items_given() {
178        use crate::BatchError;
179        let writer = SqliteItemWriter::<String>::new().table("t");
180        let result = writer.write(&["x".to_string()]);
181        match result.err().unwrap() {
182            BatchError::ItemWriter(msg) => assert!(msg.contains("columns"), "{msg}"),
183            e => panic!("expected ItemWriter, got {e:?}"),
184        }
185    }
186
187    #[test]
188    fn should_return_error_when_pool_not_configured() {
189        use crate::BatchError;
190        let writer = SqliteItemWriter::<String>::new()
191            .table("t")
192            .add_column_binding("v".to_string(), Box::new(|s: &String| s.as_str().into()));
193        let result = writer.write(&["x".to_string()]);
194        match result.err().unwrap() {
195            BatchError::ItemWriter(msg) => assert!(msg.contains("pool"), "{msg}"),
196            e => panic!("expected ItemWriter, got {e:?}"),
197        }
198    }
199
200    #[tokio::test(flavor = "multi_thread")]
201    async fn should_write_items_to_in_memory_sqlite() {
202        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
203        sqlx::query("CREATE TABLE t (v TEXT NOT NULL)")
204            .execute(&pool)
205            .await
206            .unwrap();
207
208        let writer = SqliteItemWriter::<String>::new()
209            .pool(&pool)
210            .table("t")
211            .add_column_binding("v".to_string(), Box::new(|s: &String| s.as_str().into()));
212
213        writer
214            .write(&["hello".to_string(), "world".to_string()])
215            .unwrap();
216
217        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM t")
218            .fetch_one(&pool)
219            .await
220            .unwrap();
221        assert_eq!(count.0, 2, "both items should have been written");
222    }
223
224    #[tokio::test(flavor = "multi_thread")]
225    async fn should_return_error_when_query_fails() {
226        use crate::BatchError;
227        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
228        let writer = SqliteItemWriter::<String>::new()
229            .pool(&pool)
230            .table("nonexistent_table")
231            .add_column_binding("v".to_string(), Box::new(|s: &String| s.as_str().into()));
232
233        let result = writer.write(&["x".to_string()]);
234        match result.err().unwrap() {
235            BatchError::ItemWriter(msg) => assert!(msg.contains("SQLite"), "{msg}"),
236            e => panic!("expected ItemWriter, got {e:?}"),
237        }
238    }
239
240    #[tokio::test(flavor = "multi_thread")]
241    async fn should_write_all_items_across_multiple_batches() {
242        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
243        sqlx::query("CREATE TABLE t (v TEXT NOT NULL)")
244            .execute(&pool)
245            .await
246            .unwrap();
247
248        let writer = SqliteItemWriter::<String>::new()
249            .pool(&pool)
250            .table("t")
251            .add_column_binding("v".to_string(), Box::new(|s: &String| s.as_str().into()));
252
253        // Write more items than BIND_LIMIT / columns would allow in one INSERT
254        // (e.g. 700 items × 1 col = 700 binds, well under 65535, but the loop is exercised)
255        let items: Vec<String> = (0..700).map(|i| i.to_string()).collect();
256        writer.write(&items).unwrap();
257
258        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM t")
259            .fetch_one(&pool)
260            .await
261            .unwrap();
262        assert_eq!(count.0, 700, "all 700 items should have been written");
263    }
264
265    #[tokio::test(flavor = "multi_thread")]
266    async fn should_write_null_for_none_optional_column() {
267        let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
268        sqlx::query("CREATE TABLE t (id INTEGER NOT NULL, note TEXT)")
269            .execute(&pool)
270            .await
271            .unwrap();
272
273        #[derive(Clone, serde::Serialize)]
274        struct Row {
275            id: i32,
276            note: Option<String>,
277        }
278
279        let writer = SqliteItemWriter::<Row>::new()
280            .pool(&pool)
281            .table("t")
282            .add_column_binding("id".to_string(), Box::new(|r: &Row| r.id.into()))
283            .add_column_binding(
284                "note".to_string(),
285                Box::new(|r: &Row| r.note.clone().into()),
286            );
287
288        writer.write(&[Row { id: 1, note: None }]).unwrap();
289
290        let (note,): (Option<String>,) = sqlx::query_as("SELECT note FROM t WHERE id = 1")
291            .fetch_one(&pool)
292            .await
293            .unwrap();
294        assert!(note.is_none(), "note should be NULL in the database");
295    }
296}