spring_batch_rs/item/rdbc/
sqlite_writer.rs1use 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
11pub 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 pub(crate) fn new() -> Self {
53 Self {
54 pool: None,
55 table: None,
56 column_bindings: Vec::new(),
57 }
58 }
59
60 pub(crate) fn pool(mut self, pool: &Pool<Sqlite>) -> Self {
62 self.pool = Some(pool.clone());
63 self
64 }
65
66 pub(crate) fn table(mut self, table: &str) -> Self {
68 self.table = Some(table.to_string());
69 self
70 }
71
72 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 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}