1use std::collections::HashMap;
15
16use sz_orm_core::{dialect::get_dialect, Connection, DbError, DbType, Model, QueryBuilder, Value};
17
18struct BatchExecModel;
21
22impl Model for BatchExecModel {
23 type PrimaryKey = i64;
24 fn table_name() -> &'static str {
25 "__batch_exec__"
26 }
27 fn pk(&self) -> Self::PrimaryKey {
28 0
29 }
30 fn set_pk(&mut self, _pk: Self::PrimaryKey) {}
31}
32
33pub struct BatchExecutor<'a, C: Connection> {
53 conn: &'a mut C,
54 dialect: DbType,
55 max_batch_rows: usize,
56}
57
58impl<'a, C: Connection> BatchExecutor<'a, C> {
59 pub fn new(conn: &'a mut C, dialect: DbType) -> Self {
61 Self {
62 conn,
63 dialect,
64 max_batch_rows: 1000,
65 }
66 }
67
68 pub fn with_max_batch_rows(mut self, max: usize) -> Self {
70 self.max_batch_rows = max.max(1);
71 self
72 }
73
74 pub async fn batch_insert(
80 &mut self,
81 table: &str,
82 rows: &[HashMap<String, Value>],
83 ) -> Result<u64, DbError> {
84 if rows.is_empty() {
85 return Ok(0);
86 }
87 let dialect = get_dialect(self.dialect)?;
88 let mut total: u64 = 0;
89 for chunk in rows.chunks(self.max_batch_rows) {
90 if chunk.is_empty() {
91 continue;
92 }
93 let qb =
94 QueryBuilder::<BatchExecModel>::new(dialect.clone_box()).table(table.to_string());
95 let (sql, params) = qb.build_batch_insert_with_params(chunk);
96 if sql.is_empty() {
97 continue;
98 }
99 total += self.conn.execute_with_params(&sql, ¶ms).await?;
100 }
101 Ok(total)
102 }
103
104 pub async fn batch_upsert(
110 &mut self,
111 table: &str,
112 rows: &[HashMap<String, Value>],
113 conflict_cols: &[&str],
114 update_cols: &[&str],
115 ) -> Result<u64, DbError> {
116 if rows.is_empty() {
117 return Err(DbError::InvalidInput(
118 "batch_upsert: rows cannot be empty".to_string(),
119 ));
120 }
121 let dialect = get_dialect(self.dialect)?;
122 let mut total: u64 = 0;
123 for chunk in rows.chunks(self.max_batch_rows) {
124 if chunk.is_empty() {
125 continue;
126 }
127 let qb =
128 QueryBuilder::<BatchExecModel>::new(dialect.clone_box()).table(table.to_string());
129 let (sql, params) =
130 qb.build_batch_upsert_with_params(chunk, conflict_cols, update_cols)?;
131 total += self.conn.execute_with_params(&sql, ¶ms).await?;
132 }
133 Ok(total)
134 }
135
136 pub fn max_batch_rows(&self) -> usize {
138 self.max_batch_rows
139 }
140
141 pub async fn batch_insert_parallel(
150 &mut self,
151 table: &str,
152 rows: &[HashMap<String, Value>],
153 shard_size: usize,
154 _parallelism: usize,
155 ) -> Result<BatchParallelResult, DbError> {
156 if rows.is_empty() {
157 return Ok(BatchParallelResult {
158 total_affected: 0,
159 success_shards: 0,
160 failed_shards: Vec::new(),
161 });
162 }
163
164 let effective_shard = if shard_size == 0 {
165 self.max_batch_rows
166 } else {
167 shard_size
168 };
169
170 let dialect = get_dialect(self.dialect)?;
171 let mut total_affected: u64 = 0;
172 let mut success_shards: usize = 0;
173 let mut failed_shards: Vec<(usize, DbError)> = Vec::new();
174
175 for (shard_idx, chunk) in rows.chunks(effective_shard).enumerate() {
176 if chunk.is_empty() {
177 continue;
178 }
179 let qb =
180 QueryBuilder::<BatchExecModel>::new(dialect.clone_box()).table(table.to_string());
181 let (sql, params) = qb.build_batch_insert_with_params(chunk);
182 if sql.is_empty() {
183 continue;
184 }
185 match self.conn.execute_with_params(&sql, ¶ms).await {
186 Ok(affected) => {
187 total_affected += affected;
188 success_shards += 1;
189 }
190 Err(e) => {
191 failed_shards.push((shard_idx, e));
192 }
193 }
194 }
195
196 Ok(BatchParallelResult {
197 total_affected,
198 success_shards,
199 failed_shards,
200 })
201 }
202}
203
204#[derive(Debug)]
206pub struct BatchParallelResult {
207 pub total_affected: u64,
209 pub success_shards: usize,
211 pub failed_shards: Vec<(usize, DbError)>,
213}
214
215impl BatchParallelResult {
216 pub fn is_all_success(&self) -> bool {
218 self.failed_shards.is_empty()
219 }
220
221 pub fn total_shards(&self) -> usize {
223 self.success_shards + self.failed_shards.len()
224 }
225
226 pub fn failed_count(&self) -> usize {
228 self.failed_shards.len()
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use sz_orm_core::mock::MockConnection;
236
237 fn make_row(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
238 pairs
239 .iter()
240 .map(|(k, v)| (k.to_string(), v.clone()))
241 .collect()
242 }
243
244 #[tokio::test]
245 async fn test_batch_insert_empty_returns_zero() {
246 let mut mock = MockConnection::new();
247 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
248 let rows: Vec<HashMap<String, Value>> = vec![];
249 let result = executor.batch_insert("users", &rows).await.unwrap();
250 assert_eq!(result, 0);
251 assert!(mock.executed_sql().is_empty());
252 }
253
254 #[tokio::test]
255 async fn test_batch_insert_multi_row() {
256 let mut mock = MockConnection::new();
257 mock.expect_any().with_rows_affected(3);
258 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
259 let rows = vec![
260 make_row(&[("name", Value::String("a".to_string()))]),
261 make_row(&[("name", Value::String("b".to_string()))]),
262 make_row(&[("name", Value::String("c".to_string()))]),
263 ];
264 let result = executor.batch_insert("users", &rows).await.unwrap();
265 assert_eq!(result, 3);
266 assert_eq!(mock.executed_sql().len(), 1);
267 let sql = &mock.executed_sql()[0];
268 assert!(sql.contains("INSERT INTO"));
269 assert!(sql.contains("VALUES"));
270 assert!(sql.contains("?"));
271 }
272
273 #[tokio::test]
274 async fn test_batch_insert_auto_shard() {
275 let mut mock = MockConnection::new();
276 mock.expect_any().with_rows_affected(1000);
277 mock.expect_any().with_rows_affected(500);
278 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL).with_max_batch_rows(1000);
279 let rows: Vec<HashMap<String, Value>> = (0..1500)
280 .map(|i| make_row(&[("id", Value::I64(i))]))
281 .collect();
282 let result = executor.batch_insert("t", &rows).await.unwrap();
283 assert_eq!(result, 1500);
284 assert_eq!(mock.executed_sql().len(), 2);
285 }
286
287 #[tokio::test]
288 async fn test_batch_upsert_conflict() {
289 let mut mock = MockConnection::new();
290 mock.expect_any().with_rows_affected(100);
291 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
292 let rows: Vec<HashMap<String, Value>> = (0..100)
293 .map(|i| {
294 make_row(&[
295 ("id", Value::I64(i)),
296 ("name", Value::String(format!("user{}", i))),
297 ])
298 })
299 .collect();
300 let result = executor
301 .batch_upsert("users", &rows, &["id"], &["name"])
302 .await
303 .unwrap();
304 assert_eq!(result, 100);
305 let sql = &mock.executed_sql()[0];
306 assert!(sql.contains("INSERT INTO"));
307 assert!(sql.contains("ON DUPLICATE KEY UPDATE") || sql.contains("ON CONFLICT"));
308 }
309
310 #[tokio::test]
311 async fn test_batch_upsert_pg_conflict() {
312 let mut mock = MockConnection::new();
313 mock.expect_any().with_rows_affected(2);
314 let mut executor = BatchExecutor::new(&mut mock, DbType::PostgreSQL);
315 let rows = vec![
316 make_row(&[
317 ("id", Value::I64(1)),
318 ("name", Value::String("a".to_string())),
319 ]),
320 make_row(&[
321 ("id", Value::I64(2)),
322 ("name", Value::String("b".to_string())),
323 ]),
324 ];
325 let result = executor
326 .batch_upsert("users", &rows, &["id"], &["name"])
327 .await
328 .unwrap();
329 assert_eq!(result, 2);
330 let sql = &mock.executed_sql()[0];
331 assert!(sql.contains("ON CONFLICT"));
332 }
333
334 #[tokio::test]
335 async fn test_batch_insert_column_mismatch() {
336 let mut mock = MockConnection::new();
337 mock.expect_any().with_rows_affected(2);
338 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
339 let rows = vec![
340 make_row(&[("a", Value::I64(1)), ("b", Value::I64(2))]),
341 make_row(&[("a", Value::I64(3))]),
342 ];
343 let result = executor.batch_insert("t", &rows).await.unwrap();
344 assert_eq!(result, 2);
345 }
346
347 #[tokio::test]
348 async fn test_batch_insert_parametrized_no_injection() {
349 let mut mock = MockConnection::new();
350 mock.expect_any().with_rows_affected(1);
351 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
352 let row = make_row(&[("name", Value::String("'; DROP TABLE users; --".to_string()))]);
353 executor.batch_insert("users", &[row]).await.unwrap();
354 let sql = &mock.executed_sql()[0];
355 assert!(!sql.contains("DROP TABLE"));
356 assert!(sql.contains("?"));
357 }
358
359 #[tokio::test]
360 async fn test_batch_insert_constraint_violation_atomic() {
361 let mut mock =
362 MockConnection::new().with_fallback(sz_orm_core::mock::FallbackBehavior::Error);
363 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
364 let rows = vec![make_row(&[("id", Value::I64(1))])];
365 let result = executor.batch_insert("t", &rows).await;
366 assert!(result.is_err());
367 }
368
369 #[tokio::test]
370 async fn test_batch_upsert_empty_returns_error() {
371 let mut mock = MockConnection::new();
372 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
373 let rows: Vec<HashMap<String, Value>> = vec![];
374 let result = executor.batch_upsert("t", &rows, &["id"], &["name"]).await;
375 assert!(result.is_err());
376 assert!(mock.executed_sql().is_empty());
377 }
378
379 #[tokio::test]
380 async fn test_batch_insert_sqlite() {
381 let mut mock = MockConnection::new();
382 mock.expect_any().with_rows_affected(2);
383 let mut executor = BatchExecutor::new(&mut mock, DbType::Sqlite);
384 let rows = vec![
385 make_row(&[("name", Value::String("a".to_string()))]),
386 make_row(&[("name", Value::String("b".to_string()))]),
387 ];
388 let result = executor.batch_insert("t", &rows).await.unwrap();
389 assert_eq!(result, 2);
390 let sql = &mock.executed_sql()[0];
391 assert!(sql.contains("INSERT INTO"));
392 assert!(sql.contains("?"));
393 }
394
395 #[tokio::test]
396 async fn test_batch_insert_shard_boundary_exact() {
397 let mut mock = MockConnection::new();
398 mock.expect_any().with_rows_affected(100);
399 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL).with_max_batch_rows(100);
400 let rows: Vec<HashMap<String, Value>> = (0..100)
401 .map(|i| make_row(&[("id", Value::I64(i))]))
402 .collect();
403 let result = executor.batch_insert("t", &rows).await.unwrap();
404 assert_eq!(result, 100);
405 assert_eq!(mock.executed_sql().len(), 1);
406 }
407
408 #[tokio::test]
409 async fn test_max_batch_rows_min_one() {
410 let mut mock = MockConnection::new();
411 let executor = BatchExecutor::new(&mut mock, DbType::MySQL).with_max_batch_rows(0);
412 assert_eq!(executor.max_batch_rows(), 1);
413 }
414
415 #[tokio::test]
420 async fn test_batch_insert_parallel_empty() {
421 let mut mock = MockConnection::new();
422 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
423 let rows: Vec<HashMap<String, Value>> = vec![];
424 let result = executor
425 .batch_insert_parallel("users", &rows, 100, 4)
426 .await
427 .unwrap();
428 assert_eq!(result.total_affected, 0);
429 assert_eq!(result.success_shards, 0);
430 assert!(result.is_all_success());
431 }
432
433 #[tokio::test]
434 async fn test_batch_insert_parallel_result_aggregation() {
435 let mut mock = MockConnection::new();
436 for _ in 0..20 {
437 mock.expect_any().with_rows_affected(1);
438 }
439 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
440 let rows: Vec<HashMap<String, Value>> = (0..20)
441 .map(|i| make_row(&[("id", Value::I64(i))]))
442 .collect();
443
444 let result = executor
445 .batch_insert_parallel("t", &rows, 1, 4)
446 .await
447 .unwrap();
448
449 assert_eq!(result.total_affected, 20);
450 assert_eq!(result.success_shards, 20);
451 assert!(result.is_all_success());
452 assert_eq!(result.failed_shards.len(), 0);
453 }
454
455 #[tokio::test]
456 async fn test_batch_insert_parallel_shard_size() {
457 let mut mock = MockConnection::new();
458 mock.expect_any().with_rows_affected(5);
459 mock.expect_any().with_rows_affected(5);
460 mock.expect_any().with_rows_affected(3);
461 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
462 let rows: Vec<HashMap<String, Value>> = (0..13)
463 .map(|i| make_row(&[("id", Value::I64(i))]))
464 .collect();
465
466 let result = executor
467 .batch_insert_parallel("t", &rows, 5, 2)
468 .await
469 .unwrap();
470
471 assert_eq!(result.total_affected, 13);
472 assert_eq!(result.success_shards, 3);
473 assert!(result.is_all_success());
474 }
475
476 #[tokio::test]
477 async fn test_batch_insert_parallel_degrade_on_insufficient_conns() {
478 let mut mock = MockConnection::new();
479 for _ in 0..10 {
480 mock.expect_any().with_rows_affected(1);
481 }
482 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
483 let rows: Vec<HashMap<String, Value>> = (0..10)
484 .map(|i| make_row(&[("id", Value::I64(i))]))
485 .collect();
486
487 let result = executor
488 .batch_insert_parallel("t", &rows, 1, 4)
489 .await
490 .unwrap();
491
492 assert_eq!(result.total_affected, 10);
493 assert_eq!(result.success_shards, 10);
494 assert!(result.is_all_success());
495 }
496
497 #[tokio::test]
498 async fn test_batch_insert_parallel_continue_on_error() {
499 let mut mock =
500 MockConnection::new().with_fallback(sz_orm_core::mock::FallbackBehavior::Error);
501 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL);
502 let rows: Vec<HashMap<String, Value>> =
503 (0..3).map(|i| make_row(&[("id", Value::I64(i))])).collect();
504
505 let result = executor
506 .batch_insert_parallel("t", &rows, 1, 1)
507 .await
508 .unwrap();
509
510 assert_eq!(result.success_shards, 0);
511 assert_eq!(result.failed_shards.len(), 3);
512 assert!(!result.is_all_success());
513 }
514
515 #[tokio::test]
516 async fn test_batch_insert_legacy_unchanged() {
517 let mut mock = MockConnection::new();
518 mock.expect_any().with_rows_affected(100);
519 let mut executor = BatchExecutor::new(&mut mock, DbType::MySQL).with_max_batch_rows(100);
520 let rows: Vec<HashMap<String, Value>> = (0..100)
521 .map(|i| make_row(&[("id", Value::I64(i))]))
522 .collect();
523 let result = executor.batch_insert("t", &rows).await.unwrap();
524 assert_eq!(result, 100);
525 assert_eq!(mock.executed_sql().len(), 1);
526 }
527
528 #[test]
529 fn test_batch_parallel_result_methods() {
530 let result = BatchParallelResult {
531 total_affected: 90000,
532 success_shards: 18,
533 failed_shards: vec![(5, DbError::InvalidInput("test".to_string()))],
534 };
535 assert_eq!(result.total_affected, 90000);
536 assert_eq!(result.success_shards, 18);
537 assert_eq!(result.failed_count(), 1);
538 assert_eq!(result.total_shards(), 19);
539 assert!(!result.is_all_success());
540 }
541
542 #[test]
543 fn test_batch_parallel_result_all_success() {
544 let result = BatchParallelResult {
545 total_affected: 100,
546 success_shards: 5,
547 failed_shards: Vec::new(),
548 };
549 assert!(result.is_all_success());
550 assert_eq!(result.total_shards(), 5);
551 assert_eq!(result.failed_count(), 0);
552 }
553}