Skip to main content

rust_ef/change_executor/
executor_dml.rs

1//! `ChangeExecutor` — UPDATE and DELETE execution (batched + per-row fallback).
2
3use crate::entity::{IEntitySnapshot, IEntityType, IGetKeyValues};
4use crate::entity_snapshot::EntitySnapshot;
5use crate::error::{EFError, EFResult};
6use crate::metadata::{EntityTypeMeta, PropertyMeta};
7use crate::provider::{DbValue, IAsyncConnection, IDatabaseProvider};
8use crate::query::{collect_bool_expr_values, compile_bool_expr, BoolExpr};
9
10use super::executor::ChangeExecutor;
11
12impl ChangeExecutor {
13    /// Executes UPDATE statements for all modified entities.
14    ///
15    /// When no concurrency tokens are present and the entity has a single-column
16    /// primary key, rows are batched into a single `UPDATE ... SET col = CASE
17    /// pk WHEN ? THEN ? ... END WHERE pk IN (...)` statement (≤900 params per
18    /// batch) to minimize round trips. Otherwise (concurrency tokens, composite
19    /// PK), falls back to per-row UPDATE so optimistic-concurrency checks run
20    /// on each row.
21    ///
22    /// When `modified_properties` is populated (via `detect_changes`), only the
23    /// dirty columns are SET. When empty (entity marked Modified via `update()`
24    /// without detection), all non-PK columns are SET (backward compatible).
25    ///
26    /// When `query_filter` is `Some`, the filter is AND-ed into the WHERE
27    /// clause so updates cannot cross the filter boundary (multi-tenant /
28    /// soft-delete isolation).
29    #[allow(clippy::type_complexity)]
30    pub async fn execute_updates<E>(
31        conn: &mut dyn IAsyncConnection,
32        provider: &dyn IDatabaseProvider,
33        entities: &[(&E, &EntityTypeMeta, Option<&EntitySnapshot>, &[String])],
34        query_filter: Option<&BoolExpr>,
35    ) -> EFResult<usize>
36    where
37        E: IEntityType + IEntitySnapshot + IGetKeyValues,
38    {
39        if entities.is_empty() {
40            return Ok(0);
41        }
42        let gen = provider.sql_generator();
43        let meta = entities[0].1;
44        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
45        let pk_props: Vec<_> = scalar_props.iter().filter(|p| p.is_primary_key).collect();
46        let has_concurrency_tokens = scalar_props.iter().any(|p| p.is_concurrency_token);
47
48        // Compute the union of modified field names across all entities. When
49        // non-empty, only those columns are SET (partial update). When empty
50        // (no change detection ran), all non-PK columns are SET.
51        let modified_fields: std::collections::HashSet<&str> = entities
52            .iter()
53            .flat_map(|(_, _, _, mods)| mods.iter().map(|s| s.as_str()))
54            .collect();
55        let set_props: Vec<&PropertyMeta> = if modified_fields.is_empty() {
56            scalar_props
57                .iter()
58                .copied()
59                .filter(|p| !p.is_primary_key)
60                .collect()
61        } else {
62            scalar_props
63                .iter()
64                .copied()
65                .filter(|p| !p.is_primary_key && modified_fields.contains(p.field_name.as_ref()))
66                .collect()
67        };
68        let set_cols: Vec<&str> = set_props.iter().map(|p| p.column_name.as_ref()).collect();
69
70        // Fall back to per-row UPDATE when optimistic concurrency tokens are
71        // present (each row needs its own WHERE to check the token) or when
72        // the PK is composite (CASE WHEN only works with a single column).
73        if has_concurrency_tokens || pk_props.len() != 1 || set_cols.is_empty() {
74            return Self::execute_updates_per_row(conn, gen, entities, query_filter).await;
75        }
76
77        let pk_col = pk_props[0].column_name.as_ref();
78        let pk_field = pk_props[0].field_name.as_ref();
79
80        // Pre-compute snapshots and keys to avoid re-hashing per batch.
81        let entity_data: Vec<(EntitySnapshot, EntitySnapshot)> = entities
82            .iter()
83            .map(|(e, _, _, _)| (e.snapshot(), e.key_values()))
84            .collect();
85
86        // Filter params are constant across batches.
87        let filter_params: Vec<DbValue> = match query_filter {
88            Some(filter) => collect_bool_expr_values(filter),
89            None => Vec::new(),
90        };
91
92        // For non-numbered placeholder dialects (SQLite/MySQL `?`), the filter
93        // SQL is index-independent — compile once and reuse across batches.
94        // PostgreSQL (`$N`) must recompile per batch because numbering shifts.
95        let cached_filter_sql: Option<String> = if !gen.uses_numbered_placeholders() {
96            query_filter.map(|f| {
97                let mut idx = 1;
98                compile_bool_expr(f, gen, &mut idx)
99            })
100        } else {
101            None
102        };
103
104        // Each row consumes 2 * set_cols params (CASE WHEN pk/value pairs)
105        // plus 1 param in the WHERE IN-list.
106        const MAX_PARAMS: usize = 900;
107        let params_per_row = 2 * set_cols.len() + 1;
108        let batch_size =
109            ((MAX_PARAMS.saturating_sub(filter_params.len())) / params_per_row.max(1)).max(1);
110
111        let mut updated = 0usize;
112        let mut start = 0usize;
113        while start < entity_data.len() {
114            let end = (start + batch_size).min(entity_data.len());
115            let row_count = end - start;
116
117            // SET clause consumes 2 * set_cols * row_count placeholders.
118            let set_param_count = 2 * set_cols.len() * row_count;
119            let mut idx = set_param_count + 1;
120
121            // Build WHERE clause: pk_col IN (?, ...) [AND (filter)]
122            let pk_placeholders: Vec<String> = (0..row_count)
123                .map(|_| {
124                    let ph = gen.parameter_placeholder(idx);
125                    idx += 1;
126                    ph
127                })
128                .collect();
129            let mut where_clause = format!(
130                "{} IN ({})",
131                gen.quote_identifier(pk_col),
132                pk_placeholders.join(", ")
133            );
134
135            // CASE WHEN params: for each col, for each entity: [pk_value, col_value]
136            let mut params: Vec<DbValue> = Vec::with_capacity(set_param_count + row_count);
137            for col_prop in &set_props {
138                for (snap, keys) in entity_data[start..end].iter() {
139                    let pk_val = keys.get(pk_field).cloned().unwrap_or(DbValue::Null);
140                    let col_val = snap
141                        .get(col_prop.field_name.as_ref())
142                        .cloned()
143                        .unwrap_or(DbValue::Null);
144                    params.push(pk_val);
145                    params.push(col_val);
146                }
147            }
148            // WHERE IN params: pk values
149            for (_, keys) in entity_data[start..end].iter() {
150                let pk_val = keys.get(pk_field).cloned().unwrap_or(DbValue::Null);
151                params.push(pk_val);
152            }
153
154            // Append filter to WHERE clause.
155            if let Some(filter) = query_filter {
156                let filter_sql = match &cached_filter_sql {
157                    Some(cached) => cached.clone(),
158                    None => compile_bool_expr(filter, gen, &mut idx),
159                };
160                params.extend(filter_params.iter().cloned());
161                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
162            }
163
164            let sql = gen.update_batch(
165                meta.table_name.as_ref(),
166                &set_cols,
167                pk_col,
168                row_count,
169                &where_clause,
170            );
171            let rows = conn.execute(&sql, &params).await?;
172            if rows == 0 && row_count > 0 {
173                return Err(EFError::concurrency_conflict(format!(
174                    "batch update affected 0 rows on {} (rows may have been modified or deleted)",
175                    meta.table_name
176                )));
177            }
178            updated += (rows as usize).min(row_count);
179            start = end;
180        }
181
182        Ok(updated)
183    }
184
185    /// Per-row UPDATE fallback used when concurrency tokens or composite PKs
186    /// prevent batching. Each row gets its own `UPDATE ... SET ... WHERE
187    /// pk = ? AND ...`. When `modified_properties` is non-empty for an entity,
188    /// only those columns are SET (partial update); otherwise all non-PK
189    /// columns are SET (backward compatible).
190    #[allow(clippy::type_complexity)]
191    async fn execute_updates_per_row<E>(
192        conn: &mut dyn IAsyncConnection,
193        gen: &'static dyn crate::provider::ISqlGenerator,
194        entities: &[(&E, &EntityTypeMeta, Option<&EntitySnapshot>, &[String])],
195        query_filter: Option<&BoolExpr>,
196    ) -> EFResult<usize>
197    where
198        E: IEntityType + IEntitySnapshot + IGetKeyValues,
199    {
200        let mut updated = 0;
201
202        // Hoist metadata-derived collections outside the per-entity loop —
203        // all entities share the same EntityTypeMeta (same type E), so
204        // scalar_props and concurrency_tokens are identical for every row.
205        let meta0 = entities[0].1;
206        let scalar_props: Vec<&PropertyMeta> = meta0.mapped_scalar_properties().collect();
207        let concurrency_tokens: Vec<&PropertyMeta> = scalar_props
208            .iter()
209            .copied()
210            .filter(|p| p.is_concurrency_token)
211            .collect();
212        let table_name = meta0.table_name.as_ref();
213
214        for (entity, _meta, original, modified_props) in entities {
215            let snap = entity.snapshot();
216            let keys = entity.key_values();
217
218            // When modified_properties is populated, SET only those columns
219            // (partial update). When empty, SET all non-PK columns.
220            let set_props: Vec<&PropertyMeta> = if modified_props.is_empty() {
221                scalar_props
222                    .iter()
223                    .copied()
224                    .filter(|p| !p.is_primary_key)
225                    .collect()
226            } else {
227                let modified_set: std::collections::HashSet<&str> =
228                    modified_props.iter().map(|s| s.as_str()).collect();
229                scalar_props
230                    .iter()
231                    .copied()
232                    .filter(|p| !p.is_primary_key && modified_set.contains(p.field_name.as_ref()))
233                    .collect()
234            };
235            let set_cols: Vec<&str> = set_props.iter().map(|p| p.column_name.as_ref()).collect();
236
237            if set_cols.is_empty() || keys.is_empty() {
238                continue;
239            }
240
241            let (mut where_clause, mut where_params) = build_where_with_concurrency(
242                gen,
243                &keys,
244                &concurrency_tokens,
245                *original,
246                set_cols.len() + 1,
247            )?;
248
249            if let Some(filter) = query_filter {
250                let mut idx = set_cols.len() + where_params.len() + 1;
251                let filter_sql = compile_bool_expr(filter, gen, &mut idx);
252                where_params.extend(collect_bool_expr_values(filter));
253                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
254            }
255
256            let sql = gen.update(table_name, &set_cols, &where_clause);
257
258            let mut params: Vec<DbValue> = set_props
259                .iter()
260                .map(|p| {
261                    snap.get(p.field_name.as_ref())
262                        .cloned()
263                        .unwrap_or(DbValue::Null)
264                })
265                .collect();
266            params.extend(where_params);
267
268            let rows = conn.execute(&sql, &params).await?;
269            if rows == 0 {
270                return Err(EFError::concurrency_conflict(format!(
271                    "update affected 0 rows on {} (row may have been modified or deleted)",
272                    table_name
273                )));
274            }
275            updated += 1;
276        }
277
278        Ok(updated)
279    }
280
281    /// Executes DELETE statements for all deleted entities.
282    ///
283    /// When no concurrency tokens are present and the entity has a single-column
284    /// primary key, rows are batched into `DELETE ... WHERE pk IN (?, ?, ...)`
285    /// statements (≤900 params per batch) to minimize round trips. Otherwise
286    /// (concurrency tokens, composite PK), falls back to per-row DELETE so
287    /// optimistic-concurrency checks run on each row.
288    ///
289    /// When `query_filter` is `Some`, the filter is AND-ed into the WHERE
290    /// clause so deletes cannot cross the filter boundary.
291    #[allow(clippy::type_complexity)]
292    pub async fn execute_deletes<E>(
293        conn: &mut dyn IAsyncConnection,
294        provider: &dyn IDatabaseProvider,
295        entities: &[(&E, &EntityTypeMeta, Option<&EntitySnapshot>)],
296        query_filter: Option<&BoolExpr>,
297    ) -> EFResult<usize>
298    where
299        E: IEntityType + IGetKeyValues,
300    {
301        if entities.is_empty() {
302            return Ok(0);
303        }
304        let gen = provider.sql_generator();
305        let meta = entities[0].1;
306        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
307        let has_concurrency_tokens = scalar_props.iter().any(|p| p.is_concurrency_token);
308        let pk_props: Vec<_> = scalar_props.iter().filter(|p| p.is_primary_key).collect();
309
310        // Fall back to per-row DELETE when optimistic concurrency tokens are
311        // present (each row needs its own WHERE to check the token) or when
312        // the PK is composite (IN clause only works for a single column).
313        if has_concurrency_tokens || pk_props.len() != 1 {
314            return Self::execute_deletes_per_row(conn, gen, entities, query_filter).await;
315        }
316
317        let pk_col = pk_props[0].column_name.as_ref();
318        let pk_field = pk_props[0].field_name.as_ref();
319
320        // Collect PK values; entities missing the PK value are skipped.
321        let pk_values: Vec<DbValue> = entities
322            .iter()
323            .filter_map(|(e, _, _)| e.key_values().get(pk_field).cloned())
324            .collect();
325        if pk_values.is_empty() {
326            return Ok(0);
327        }
328
329        // Filter params are constant across batches; their SQL is recomputed
330        // per batch for numbered-placeholder dialects (Postgres `$N`).
331        // For `?` dialects (SQLite/MySQL), the filter SQL is index-independent
332        // and can be compiled once and reused.
333        let filter_params: Vec<DbValue> = match query_filter {
334            Some(filter) => collect_bool_expr_values(filter),
335            None => Vec::new(),
336        };
337        let cached_filter_sql: Option<String> = if !gen.uses_numbered_placeholders() {
338            query_filter.map(|f| {
339                let mut idx = 1;
340                compile_bool_expr(f, gen, &mut idx)
341            })
342        } else {
343            None
344        };
345        const MAX_PARAMS: usize = 900;
346        let batch_size = MAX_PARAMS.saturating_sub(filter_params.len()).max(1);
347
348        let mut deleted = 0usize;
349        let mut start = 0usize;
350        while start < pk_values.len() {
351            let end = (start + batch_size).min(pk_values.len());
352            let batch = &pk_values[start..end];
353            let pk_count = batch.len();
354
355            let pk_placeholders: Vec<String> = (1..=pk_count)
356                .map(|i| gen.parameter_placeholder(i))
357                .collect();
358            let mut where_clause = format!(
359                "{} IN ({})",
360                gen.quote_identifier(pk_col),
361                pk_placeholders.join(", "),
362            );
363            let mut params: Vec<DbValue> = batch.to_vec();
364            if let Some(filter) = query_filter {
365                let filter_sql = match &cached_filter_sql {
366                    Some(cached) => cached.clone(),
367                    None => {
368                        let mut idx = pk_count + 1;
369                        compile_bool_expr(filter, gen, &mut idx)
370                    }
371                };
372                params.extend(filter_params.iter().cloned());
373                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
374            }
375
376            let sql = gen.delete(meta.table_name.as_ref(), &where_clause);
377            let rows = conn.execute(&sql, &params).await?;
378            if rows == 0 && pk_count > 0 {
379                return Err(EFError::concurrency_conflict(format!(
380                    "batch delete affected 0 rows on {} (rows may have been modified or deleted)",
381                    meta.table_name
382                )));
383            }
384            deleted += (rows as usize).min(pk_count);
385            start = end;
386        }
387
388        Ok(deleted)
389    }
390
391    /// Per-row DELETE fallback used when concurrency tokens or composite PKs
392    /// prevent batching. Each row gets its own `DELETE ... WHERE pk = ? AND ...`.
393    #[allow(clippy::type_complexity)]
394    async fn execute_deletes_per_row<E>(
395        conn: &mut dyn IAsyncConnection,
396        gen: &'static dyn crate::provider::ISqlGenerator,
397        entities: &[(&E, &EntityTypeMeta, Option<&EntitySnapshot>)],
398        query_filter: Option<&BoolExpr>,
399    ) -> EFResult<usize>
400    where
401        E: IEntityType + IGetKeyValues,
402    {
403        let mut deleted = 0;
404
405        // Hoist metadata-derived collections outside the per-entity loop —
406        // all entities share the same EntityTypeMeta (same type E).
407        let meta0 = entities[0].1;
408        let concurrency_tokens: Vec<&PropertyMeta> = meta0
409            .mapped_scalar_properties()
410            .filter(|p| p.is_concurrency_token)
411            .collect();
412        let table_name = meta0.table_name.as_ref();
413
414        for (entity, _meta, original) in entities {
415            let keys = entity.key_values();
416            if keys.is_empty() {
417                continue;
418            }
419
420            let (mut where_clause, mut where_params) =
421                build_where_with_concurrency(gen, &keys, &concurrency_tokens, *original, 1)?;
422
423            if let Some(filter) = query_filter {
424                let mut idx = where_params.len() + 1;
425                let filter_sql = compile_bool_expr(filter, gen, &mut idx);
426                where_params.extend(collect_bool_expr_values(filter));
427                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
428            }
429
430            let sql = gen.delete(table_name, &where_clause);
431            let rows = conn.execute(&sql, &where_params).await?;
432            if rows == 0 {
433                return Err(EFError::concurrency_conflict(format!(
434                    "delete affected 0 rows on {} (row may have been modified or deleted)",
435                    table_name
436                )));
437            }
438            deleted += 1;
439        }
440        Ok(deleted)
441    }
442}
443
444fn build_where_with_concurrency(
445    gen: &dyn crate::provider::ISqlGenerator,
446    keys: &EntitySnapshot,
447    concurrency_tokens: &[&PropertyMeta],
448    original: Option<&EntitySnapshot>,
449    start_param_idx: usize,
450) -> EFResult<(String, Vec<DbValue>)> {
451    let mut where_parts: Vec<String> = keys
452        .iter()
453        .enumerate()
454        .map(|(i, (k, _))| {
455            format!(
456                "{} = {}",
457                gen.quote_identifier(k),
458                gen.parameter_placeholder(start_param_idx + i)
459            )
460        })
461        .collect();
462
463    let mut params: Vec<DbValue> = keys.iter().map(|(_, v)| v.clone()).collect();
464
465    for (next_idx, token) in (start_param_idx + keys.len()..).zip(concurrency_tokens.iter()) {
466        where_parts.push(format!(
467            "{} = {}",
468            gen.quote_identifier(token.column_name.as_ref()),
469            gen.parameter_placeholder(next_idx)
470        ));
471
472        let original_val = original
473            .and_then(|o| o.get(token.field_name.as_ref()))
474            .ok_or_else(|| {
475                EFError::change_tracking(format!(
476                    "missing original concurrency token for '{}'",
477                    token.field_name
478                ))
479            })?;
480        params.push(original_val.clone());
481    }
482
483    Ok((where_parts.join(" AND "), params))
484}