Skip to main content

sz_orm_sqlx/
batch_executor.rs

1//! v9.5.0 BatchExecutor:端到端批量执行
2//!
3//! 串联 `QueryBuilder::build_batch_insert_with_params`(`query.rs:2953`)
4//! → `Connection::execute_with_params`(`pool.rs:98`),
5//! 含自动分片、空批边界、UPSERT 端到端。
6//!
7//! # 设计要点
8//!
9//! - **参数化**:所有值通过 `?`/`$N` 占位符绑定,杜绝 SQL 注入(spec 5.1.1 规则 5)
10//! - **空批短路**:空 `rows` 返回 `Ok(0)`,不执行 SQL(spec 5.1.1 规则 4)
11//! - **自动分片**:`rows.len() > max_batch_rows` 时按 `max_batch_rows` 分片执行并聚合影响行数
12//! - **多值 INSERT**:生成单条多值 INSERT SQL,非逐行循环(spec 5.1.1 规则 6)
13
14use std::collections::HashMap;
15
16use sz_orm_core::{dialect::get_dialect, Connection, DbError, DbType, Model, QueryBuilder, Value};
17
18/// 内部占位 Model,用于 QueryBuilder 构造(table 由 `table()` 方法显式设置,
19/// 不依赖 `Model::table_name()`)。
20struct 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
33/// v9.5.0 批量执行器:端到端批量 INSERT/UPSERT。
34///
35/// 串联 `QueryBuilder::build_batch_insert_with_params` → `Connection::execute_with_params`,
36/// 含自动分片、空批边界。
37///
38/// # 示例
39///
40/// ```no_run
41/// use sz_orm_core::{Connection, DbType, Value};
42/// use sz_orm_sqlx::BatchExecutor;
43/// use std::collections::HashMap;
44///
45/// # async fn example(conn: &mut impl Connection) {
46/// let mut executor = BatchExecutor::new(conn, DbType::MySQL);
47/// let mut row = HashMap::new();
48/// row.insert("name".to_string(), Value::String("alice".to_string()));
49/// let affected = executor.batch_insert("users", &[row]).await.unwrap();
50/// # }
51/// ```
52pub 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    /// 创建批量执行器,默认 `max_batch_rows = 1000`。
60    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    /// 设置每批最大行数(自动分片阈值),最小为 1。
69    pub fn with_max_batch_rows(mut self, max: usize) -> Self {
70        self.max_batch_rows = max.max(1);
71        self
72    }
73
74    /// 批量 INSERT:生成多值 INSERT SQL 并执行。
75    ///
76    /// - 空批返回 `Ok(0)`,不执行 SQL(spec 5.1.1 规则 4)
77    /// - 自动分片:`rows.len() > max_batch_rows` 时分多批执行并聚合影响行数
78    /// - 所有值参数化绑定,杜绝 SQL 注入(spec 5.1.1 规则 5)
79    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, &params).await?;
100        }
101        Ok(total)
102    }
103
104    /// 批量 UPSERT:生成 INSERT...ON DUPLICATE KEY UPDATE / ON CONFLICT UPDATE SQL 并执行。
105    ///
106    /// - `conflict_cols`:冲突检测列(主键/唯一键)
107    /// - `update_cols`:冲突时更新的列;空切片表示更新所有非冲突列
108    /// - 空批返回 `Err(InvalidInput)`(与 `build_batch_upsert_with_params` 一致)
109    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, &params).await?;
132        }
133        Ok(total)
134    }
135
136    /// 返回当前最大批次行数(用于测试断言)。
137    pub fn max_batch_rows(&self) -> usize {
138        self.max_batch_rows
139    }
140
141    /// v9.6.0 并行分片批量 INSERT:拆分行为多个分片,逐片执行并聚合结果。
142    ///
143    /// 当前使用 `self.conn` 串行执行各分片(单连接降级模式)。
144    /// 真正的多连接并行需通过 `batch_insert_parallel_with_pool` 实现。
145    ///
146    /// - `shard_size`:每片行数(≤ 1 时按 `max_batch_rows` 分片)
147    /// - `parallelism`:期望并行度(当前降级为串行,参数保留兼容)
148    /// - 某分片失败时其他分片继续(Continue 策略),已成功分片不回滚
149    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, &params).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/// v9.6.0 并行批量执行结果
205#[derive(Debug)]
206pub struct BatchParallelResult {
207    /// 总影响行数(所有成功分片之和)
208    pub total_affected: u64,
209    /// 成功分片数
210    pub success_shards: usize,
211    /// 失败分片列表:(分片索引, 错误)
212    pub failed_shards: Vec<(usize, DbError)>,
213}
214
215impl BatchParallelResult {
216    /// 是否所有分片均成功
217    pub fn is_all_success(&self) -> bool {
218        self.failed_shards.is_empty()
219    }
220
221    /// 总分片数
222    pub fn total_shards(&self) -> usize {
223        self.success_shards + self.failed_shards.len()
224    }
225
226    /// 失败分片数
227    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    // ========================================================================
416    // v9.6.0 batch_insert_parallel 测试
417    // ========================================================================
418
419    #[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}