Skip to main content

rust_ef/
change_executor.rs

1//! Change executor — generates and executes SQL for entity state changes.
2//!
3//! The `ChangeExecutor` takes a collection of tracked entities grouped by
4//! state (Added/Modified/Deleted), generates the appropriate parameterized
5//! DML, and executes it against the database via the provider.
6
7use crate::entity::{IEntitySnapshot, IEntityType, IGetKeyValues};
8use crate::error::{EFError, EFResult};
9use crate::metadata::{EntityTypeMeta, PropertyMeta};
10use crate::provider::{DbValue, IAsyncConnection, IDatabaseProvider};
11use crate::query::{collect_bool_expr_values, compile_bool_expr, BoolExpr};
12use std::collections::HashMap;
13
14/// Executes INSERT/UPDATE/DELETE for tracked entities within a transaction.
15pub struct ChangeExecutor;
16
17impl ChangeExecutor {
18    /// Executes INSERT statements for all added entities.
19    ///
20    /// Rows are batched into multi-value `INSERT INTO ... VALUES (...), (...)`
21    /// statements to minimize round trips. Batches are sized so that the total
22    /// parameter count stays ≤ 900 (SQLite's variable limit is 999; we use a
23    /// conservative ceiling that also fits MySQL/PG). Auto-increment columns
24    /// are excluded from the column list (the DB assigns them).
25    ///
26    /// `on_key_backfill` is invoked once per inserted row with the
27    /// database-generated auto-increment PK (when present). For PostgreSQL the
28    /// PKs are read from the `RETURNING *` result set; for SQLite/MySQL a
29    /// follow-up `last_insert_rowid()` / `LAST_INSERT_ID()` query retrieves
30    /// the first/last ID and the remaining batch keys are computed by
31    /// sequential increment.
32    pub async fn execute_inserts<E, F>(
33        conn: &mut dyn IAsyncConnection,
34        provider: &dyn IDatabaseProvider,
35        entities: &[(&E, &EntityTypeMeta)],
36        mut on_key_backfill: F,
37    ) -> EFResult<usize>
38    where
39        E: IEntityType + IEntitySnapshot + IGetKeyValues,
40        F: FnMut(usize, i64),
41    {
42        if entities.is_empty() {
43            return Ok(0);
44        }
45        let gen = provider.sql_generator();
46        let meta = entities[0].1;
47        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
48        if scalar_props.is_empty() {
49            return Ok(0);
50        }
51        let insert_cols: Vec<&str> = scalar_props
52            .iter()
53            .filter(|p| !p.is_primary_key || (!p.is_auto_increment && !p.is_sequence))
54            .map(|p| p.column_name.as_ref())
55            .collect();
56        if insert_cols.is_empty() {
57            return Ok(0);
58        }
59
60        // Identify the auto-increment/sequence PK column (if any) for key backfill.
61        let auto_inc_pk = scalar_props
62            .iter()
63            .find(|p| (p.is_auto_increment || p.is_sequence) && p.is_primary_key);
64
65        // Conservative per-statement parameter ceiling (SQLite limit 999).
66        const MAX_PARAMS: usize = 900;
67        let batch_size = (MAX_PARAMS / insert_cols.len()).max(1);
68
69        let mut inserted = 0usize;
70        let mut start = 0usize;
71        while start < entities.len() {
72            let end = (start + batch_size).min(entities.len());
73            let batch = &entities[start..end];
74            let row_count = batch.len();
75
76            let sql = gen.insert_batch(meta.table_name.as_ref(), &insert_cols, row_count);
77            let mut params: Vec<DbValue> = Vec::with_capacity(row_count * insert_cols.len());
78            for (entity, _) in batch {
79                let snap = entity.snapshot();
80                for p in &scalar_props {
81                    if !p.is_primary_key || (!p.is_auto_increment && !p.is_sequence) {
82                        params.push(
83                            snap.get(p.field_name.as_ref())
84                                .cloned()
85                                .unwrap_or(DbValue::Null),
86                        );
87                    }
88                }
89            }
90
91            if let Some(_pk_prop) = auto_inc_pk {
92                if gen.supports_returning() {
93                    // PostgreSQL: INSERT ... RETURNING * returns all generated
94                    // rows. Extract the PK column from each returned row.
95                    let rows = conn.query(&sql, &params).await?;
96                    let affected = rows.len().min(row_count);
97                    for (i, row) in rows.iter().enumerate().take(affected) {
98                        if let Some(pk_val) = row.first() {
99                            if let Ok(key) = pk_val.clone().try_into() {
100                                on_key_backfill(start + i, key);
101                            }
102                        }
103                    }
104                    inserted += affected;
105                } else if let Some(last_id_sql) = gen.last_insert_id_sql() {
106                    // SQLite/MySQL: execute INSERT, then query for the
107                    // generated ID and compute the batch key sequence.
108                    conn.execute(&sql, &params).await?;
109                    let id_rows = conn.query(last_id_sql, &[]).await?;
110                    if let Some(id_row) = id_rows.first() {
111                        if let Some(id_val) = id_row.first() {
112                            let raw_id: i64 = id_val.clone().try_into().unwrap_or(0);
113                            let first_id = if gen.last_insert_id_returns_first() {
114                                raw_id
115                            } else {
116                                raw_id - row_count as i64 + 1
117                            };
118                            for i in 0..row_count {
119                                on_key_backfill(start + i, first_id + i as i64);
120                            }
121                        }
122                    }
123                    inserted += row_count;
124                } else {
125                    // No RETURNING and no last_insert_id — cannot backfill.
126                    let rows = conn.execute(&sql, &params).await?;
127                    let affected = (rows as usize).min(row_count);
128                    for i in 0..affected {
129                        on_key_backfill(start + i, 0);
130                    }
131                    inserted += affected;
132                }
133            } else {
134                // No auto-increment PK — entity already has its key.
135                let rows = conn.execute(&sql, &params).await?;
136                let affected = (rows as usize).min(row_count);
137                for i in 0..affected {
138                    on_key_backfill(start + i, 0);
139                }
140                inserted += affected;
141            }
142
143            start = end;
144        }
145
146        Ok(inserted)
147    }
148
149    /// Executes UPSERT statements (INSERT ... ON CONFLICT DO UPDATE) for
150    /// entities marked via `DbSet::upsert`.
151    ///
152    /// Unlike `execute_inserts`, ALL mapped columns (including auto-increment
153    /// PKs) are included in the INSERT column list so the caller-provided PK
154    /// value participates in the conflict check. The conflict target is the PK
155    /// column(s). The UPDATE SET clause covers all non-PK columns.
156    /// No PK backfill is performed — upsert callers are expected to know the key.
157    pub async fn execute_upserts<E>(
158        conn: &mut dyn IAsyncConnection,
159        provider: &dyn IDatabaseProvider,
160        entities: &[(&E, &EntityTypeMeta)],
161    ) -> EFResult<usize>
162    where
163        E: IEntityType + IEntitySnapshot + IGetKeyValues,
164    {
165        if entities.is_empty() {
166            return Ok(0);
167        }
168        let gen = provider.sql_generator();
169        let meta = entities[0].1;
170        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
171        if scalar_props.is_empty() {
172            return Ok(0);
173        }
174        // For upsert, include ALL mapped columns (including auto-increment PK)
175        // so the caller's key value is part of the INSERT and can trigger the
176        // ON CONFLICT path.
177        let insert_cols: Vec<&str> = scalar_props
178            .iter()
179            .map(|p| p.column_name.as_ref())
180            .collect();
181        if insert_cols.is_empty() {
182            return Ok(0);
183        }
184
185        // Conflict target: PK column names.
186        let conflict_cols: Vec<&str> = scalar_props
187            .iter()
188            .filter(|p| p.is_primary_key)
189            .map(|p| p.column_name.as_ref())
190            .collect();
191        if conflict_cols.is_empty() {
192            return Err(EFError::configuration(
193                "Upsert requires at least one primary key column as conflict target.",
194            ));
195        }
196
197        const MAX_PARAMS: usize = 900;
198        let batch_size = (MAX_PARAMS / insert_cols.len()).max(1);
199
200        let mut upserted = 0usize;
201        let mut start = 0usize;
202        while start < entities.len() {
203            let end = (start + batch_size).min(entities.len());
204            let batch = &entities[start..end];
205            let row_count = batch.len();
206
207            let sql = gen.upsert_batch(
208                meta.table_name.as_ref(),
209                &insert_cols,
210                &conflict_cols,
211                row_count,
212            );
213            let mut params: Vec<DbValue> = Vec::with_capacity(row_count * insert_cols.len());
214            for (entity, _) in batch {
215                let snap = entity.snapshot();
216                for p in &scalar_props {
217                    params.push(
218                        snap.get(p.field_name.as_ref())
219                            .cloned()
220                            .unwrap_or(DbValue::Null),
221                    );
222                }
223            }
224
225            let affected = conn.execute(&sql, &params).await?;
226            upserted += (affected as usize).min(row_count);
227
228            start = end;
229        }
230
231        Ok(upserted)
232    }
233
234    /// Executes UPDATE statements for all modified entities.
235    ///
236    /// When no concurrency tokens are present and the entity has a single-column
237    /// primary key, rows are batched into a single `UPDATE ... SET col = CASE
238    /// pk WHEN ? THEN ? ... END WHERE pk IN (...)` statement (≤900 params per
239    /// batch) to minimize round trips. Otherwise (concurrency tokens, composite
240    /// PK), falls back to per-row UPDATE so optimistic-concurrency checks run
241    /// on each row.
242    ///
243    /// When `modified_properties` is populated (via `detect_changes`), only the
244    /// dirty columns are SET. When empty (entity marked Modified via `update()`
245    /// without detection), all non-PK columns are SET (backward compatible).
246    ///
247    /// When `query_filter` is `Some`, the filter is AND-ed into the WHERE
248    /// clause so updates cannot cross the filter boundary (multi-tenant /
249    /// soft-delete isolation).
250    #[allow(clippy::type_complexity)]
251    pub async fn execute_updates<E>(
252        conn: &mut dyn IAsyncConnection,
253        provider: &dyn IDatabaseProvider,
254        entities: &[(
255            &E,
256            &EntityTypeMeta,
257            Option<&HashMap<String, DbValue>>,
258            &[String],
259        )],
260        query_filter: Option<&BoolExpr>,
261    ) -> EFResult<usize>
262    where
263        E: IEntityType + IEntitySnapshot + IGetKeyValues,
264    {
265        if entities.is_empty() {
266            return Ok(0);
267        }
268        let gen = provider.sql_generator();
269        let meta = entities[0].1;
270        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
271        let pk_props: Vec<_> = scalar_props.iter().filter(|p| p.is_primary_key).collect();
272        let has_concurrency_tokens = scalar_props.iter().any(|p| p.is_concurrency_token);
273
274        // Compute the union of modified field names across all entities. When
275        // non-empty, only those columns are SET (partial update). When empty
276        // (no change detection ran), all non-PK columns are SET.
277        let modified_fields: std::collections::HashSet<&str> = entities
278            .iter()
279            .flat_map(|(_, _, _, mods)| mods.iter().map(|s| s.as_str()))
280            .collect();
281        let set_props: Vec<&PropertyMeta> = if modified_fields.is_empty() {
282            scalar_props
283                .iter()
284                .copied()
285                .filter(|p| !p.is_primary_key)
286                .collect()
287        } else {
288            scalar_props
289                .iter()
290                .copied()
291                .filter(|p| !p.is_primary_key && modified_fields.contains(p.field_name.as_ref()))
292                .collect()
293        };
294        let set_cols: Vec<&str> = set_props.iter().map(|p| p.column_name.as_ref()).collect();
295
296        // Fall back to per-row UPDATE when optimistic concurrency tokens are
297        // present (each row needs its own WHERE to check the token) or when
298        // the PK is composite (CASE WHEN only works with a single column).
299        if has_concurrency_tokens || pk_props.len() != 1 || set_cols.is_empty() {
300            return Self::execute_updates_per_row(conn, gen, entities, query_filter).await;
301        }
302
303        let pk_col = pk_props[0].column_name.as_ref();
304        let pk_field = pk_props[0].field_name.as_ref();
305
306        // Pre-compute snapshots and keys to avoid re-hashing per batch.
307        let entity_data: Vec<(HashMap<String, DbValue>, HashMap<String, DbValue>)> = entities
308            .iter()
309            .map(|(e, _, _, _)| (e.snapshot(), e.key_values()))
310            .collect();
311
312        // Filter params are constant across batches.
313        let filter_params: Vec<DbValue> = match query_filter {
314            Some(filter) => collect_bool_expr_values(filter),
315            None => Vec::new(),
316        };
317
318        // Each row consumes 2 * set_cols params (CASE WHEN pk/value pairs)
319        // plus 1 param in the WHERE IN-list.
320        const MAX_PARAMS: usize = 900;
321        let params_per_row = 2 * set_cols.len() + 1;
322        let batch_size =
323            ((MAX_PARAMS.saturating_sub(filter_params.len())) / params_per_row.max(1)).max(1);
324
325        let mut updated = 0usize;
326        let mut start = 0usize;
327        while start < entity_data.len() {
328            let end = (start + batch_size).min(entity_data.len());
329            let row_count = end - start;
330
331            // SET clause consumes 2 * set_cols * row_count placeholders.
332            let set_param_count = 2 * set_cols.len() * row_count;
333            let mut idx = set_param_count + 1;
334
335            // Build WHERE clause: pk_col IN (?, ...) [AND (filter)]
336            let pk_placeholders: Vec<String> = (0..row_count)
337                .map(|_| {
338                    let ph = gen.parameter_placeholder(idx);
339                    idx += 1;
340                    ph
341                })
342                .collect();
343            let mut where_clause = format!(
344                "{} IN ({})",
345                gen.quote_identifier(pk_col),
346                pk_placeholders.join(", ")
347            );
348
349            // CASE WHEN params: for each col, for each entity: [pk_value, col_value]
350            let mut params: Vec<DbValue> = Vec::with_capacity(set_param_count + row_count);
351            for col_prop in &set_props {
352                for (snap, keys) in entity_data[start..end].iter() {
353                    let pk_val = keys.get(pk_field).cloned().unwrap_or(DbValue::Null);
354                    let col_val = snap
355                        .get(col_prop.field_name.as_ref())
356                        .cloned()
357                        .unwrap_or(DbValue::Null);
358                    params.push(pk_val);
359                    params.push(col_val);
360                }
361            }
362            // WHERE IN params: pk values
363            for (_, keys) in entity_data[start..end].iter() {
364                let pk_val = keys.get(pk_field).cloned().unwrap_or(DbValue::Null);
365                params.push(pk_val);
366            }
367
368            // Append filter to WHERE clause.
369            if let Some(filter) = query_filter {
370                let filter_sql = compile_bool_expr(filter, gen, &mut idx);
371                params.extend(filter_params.iter().cloned());
372                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
373            }
374
375            let sql = gen.update_batch(
376                meta.table_name.as_ref(),
377                &set_cols,
378                pk_col,
379                row_count,
380                &where_clause,
381            );
382            let rows = conn.execute(&sql, &params).await?;
383            if rows == 0 && row_count > 0 {
384                return Err(EFError::concurrency_conflict(format!(
385                    "batch update affected 0 rows on {} (rows may have been modified or deleted)",
386                    meta.table_name
387                )));
388            }
389            updated += (rows as usize).min(row_count);
390            start = end;
391        }
392
393        Ok(updated)
394    }
395
396    /// Per-row UPDATE fallback used when concurrency tokens or composite PKs
397    /// prevent batching. Each row gets its own `UPDATE ... SET ... WHERE
398    /// pk = ? AND ...`. When `modified_properties` is non-empty for an entity,
399    /// only those columns are SET (partial update); otherwise all non-PK
400    /// columns are SET (backward compatible).
401    #[allow(clippy::type_complexity)]
402    async fn execute_updates_per_row<E>(
403        conn: &mut dyn IAsyncConnection,
404        gen: &'static dyn crate::provider::ISqlGenerator,
405        entities: &[(
406            &E,
407            &EntityTypeMeta,
408            Option<&HashMap<String, DbValue>>,
409            &[String],
410        )],
411        query_filter: Option<&BoolExpr>,
412    ) -> EFResult<usize>
413    where
414        E: IEntityType + IEntitySnapshot + IGetKeyValues,
415    {
416        let mut updated = 0;
417        let mut sql_cache: HashMap<(String, Vec<String>, String), String> = HashMap::new();
418
419        for (entity, meta, original, modified_props) in entities {
420            let snap = entity.snapshot();
421            let keys = entity.key_values();
422            let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
423
424            // When modified_properties is populated, SET only those columns
425            // (partial update). When empty, SET all non-PK columns.
426            let set_props: Vec<&PropertyMeta> = if modified_props.is_empty() {
427                scalar_props
428                    .iter()
429                    .copied()
430                    .filter(|p| !p.is_primary_key)
431                    .collect()
432            } else {
433                let modified_set: std::collections::HashSet<&str> =
434                    modified_props.iter().map(|s| s.as_str()).collect();
435                scalar_props
436                    .iter()
437                    .copied()
438                    .filter(|p| !p.is_primary_key && modified_set.contains(p.field_name.as_ref()))
439                    .collect()
440            };
441            let set_cols: Vec<&str> = set_props.iter().map(|p| p.column_name.as_ref()).collect();
442
443            if set_cols.is_empty() || keys.is_empty() {
444                continue;
445            }
446
447            let concurrency_tokens: Vec<&PropertyMeta> = scalar_props
448                .iter()
449                .copied()
450                .filter(|p| p.is_concurrency_token)
451                .collect();
452
453            let (mut where_clause, mut where_params) = build_where_with_concurrency(
454                gen,
455                &keys,
456                &concurrency_tokens,
457                *original,
458                set_cols.len() + 1,
459            )?;
460
461            if let Some(filter) = query_filter {
462                let mut idx = set_cols.len() + where_params.len() + 1;
463                let filter_sql = compile_bool_expr(filter, gen, &mut idx);
464                where_params.extend(collect_bool_expr_values(filter));
465                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
466            }
467
468            let sql = sql_cache
469                .entry((
470                    meta.table_name.to_string(),
471                    set_cols.iter().map(|s| (*s).to_string()).collect(),
472                    where_clause.clone(),
473                ))
474                .or_insert_with(|| gen.update(meta.table_name.as_ref(), &set_cols, &where_clause))
475                .clone();
476
477            let mut params: Vec<DbValue> = set_props
478                .iter()
479                .map(|p| {
480                    snap.get(p.field_name.as_ref())
481                        .cloned()
482                        .unwrap_or(DbValue::Null)
483                })
484                .collect();
485            params.extend(where_params);
486
487            let rows = conn.execute(&sql, &params).await?;
488            if rows == 0 {
489                return Err(EFError::concurrency_conflict(format!(
490                    "update affected 0 rows on {} (row may have been modified or deleted)",
491                    meta.table_name
492                )));
493            }
494            updated += 1;
495        }
496
497        Ok(updated)
498    }
499
500    /// Executes DELETE statements for all deleted entities.
501    ///
502    /// When no concurrency tokens are present and the entity has a single-column
503    /// primary key, rows are batched into `DELETE ... WHERE pk IN (?, ?, ...)`
504    /// statements (≤900 params per batch) to minimize round trips. Otherwise
505    /// (concurrency tokens, composite PK), falls back to per-row DELETE so
506    /// optimistic-concurrency checks run on each row.
507    ///
508    /// When `query_filter` is `Some`, the filter is AND-ed into the WHERE
509    /// clause so deletes cannot cross the filter boundary.
510    #[allow(clippy::type_complexity)]
511    pub async fn execute_deletes<E>(
512        conn: &mut dyn IAsyncConnection,
513        provider: &dyn IDatabaseProvider,
514        entities: &[(&E, &EntityTypeMeta, Option<&HashMap<String, DbValue>>)],
515        query_filter: Option<&BoolExpr>,
516    ) -> EFResult<usize>
517    where
518        E: IEntityType + IGetKeyValues,
519    {
520        if entities.is_empty() {
521            return Ok(0);
522        }
523        let gen = provider.sql_generator();
524        let meta = entities[0].1;
525        let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
526        let has_concurrency_tokens = scalar_props.iter().any(|p| p.is_concurrency_token);
527        let pk_props: Vec<_> = scalar_props.iter().filter(|p| p.is_primary_key).collect();
528
529        // Fall back to per-row DELETE when optimistic concurrency tokens are
530        // present (each row needs its own WHERE to check the token) or when
531        // the PK is composite (IN clause only works for a single column).
532        if has_concurrency_tokens || pk_props.len() != 1 {
533            return Self::execute_deletes_per_row(conn, gen, entities, query_filter).await;
534        }
535
536        let pk_col = pk_props[0].column_name.as_ref();
537        let pk_field = pk_props[0].field_name.as_ref();
538
539        // Collect PK values; entities missing the PK value are skipped.
540        let pk_values: Vec<DbValue> = entities
541            .iter()
542            .filter_map(|(e, _, _)| e.key_values().get(pk_field).cloned())
543            .collect();
544        if pk_values.is_empty() {
545            return Ok(0);
546        }
547
548        // Filter params are constant across batches; their SQL is recomputed
549        // per batch because Postgres numbered placeholders depend on batch size.
550        let filter_params: Vec<DbValue> = match query_filter {
551            Some(filter) => collect_bool_expr_values(filter),
552            None => Vec::new(),
553        };
554        const MAX_PARAMS: usize = 900;
555        let batch_size = MAX_PARAMS.saturating_sub(filter_params.len()).max(1);
556
557        let mut deleted = 0usize;
558        let mut start = 0usize;
559        while start < pk_values.len() {
560            let end = (start + batch_size).min(pk_values.len());
561            let batch = &pk_values[start..end];
562            let pk_count = batch.len();
563
564            let pk_placeholders: Vec<String> = (1..=pk_count)
565                .map(|i| gen.parameter_placeholder(i))
566                .collect();
567            let mut where_clause = format!(
568                "{} IN ({})",
569                gen.quote_identifier(pk_col),
570                pk_placeholders.join(", "),
571            );
572            let mut params: Vec<DbValue> = batch.to_vec();
573            if let Some(filter) = query_filter {
574                // Filter placeholders are numbered after the PK IN-list.
575                let mut idx = pk_count + 1;
576                let filter_sql = compile_bool_expr(filter, gen, &mut idx);
577                params.extend(filter_params.iter().cloned());
578                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
579            }
580
581            let sql = gen.delete(meta.table_name.as_ref(), &where_clause);
582            let rows = conn.execute(&sql, &params).await?;
583            if rows == 0 && pk_count > 0 {
584                return Err(EFError::concurrency_conflict(format!(
585                    "batch delete affected 0 rows on {} (rows may have been modified or deleted)",
586                    meta.table_name
587                )));
588            }
589            deleted += (rows as usize).min(pk_count);
590            start = end;
591        }
592
593        Ok(deleted)
594    }
595
596    /// Per-row DELETE fallback used when concurrency tokens or composite PKs
597    /// prevent batching. Each row gets its own `DELETE ... WHERE pk = ? AND ...`.
598    #[allow(clippy::type_complexity)]
599    async fn execute_deletes_per_row<E>(
600        conn: &mut dyn IAsyncConnection,
601        gen: &'static dyn crate::provider::ISqlGenerator,
602        entities: &[(&E, &EntityTypeMeta, Option<&HashMap<String, DbValue>>)],
603        query_filter: Option<&BoolExpr>,
604    ) -> EFResult<usize>
605    where
606        E: IEntityType + IGetKeyValues,
607    {
608        let mut deleted = 0;
609        for (entity, meta, original) in entities {
610            let keys = entity.key_values();
611            if keys.is_empty() {
612                continue;
613            }
614            let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
615            let concurrency_tokens: Vec<&PropertyMeta> = scalar_props
616                .iter()
617                .copied()
618                .filter(|p| p.is_concurrency_token)
619                .collect();
620
621            let (mut where_clause, mut where_params) =
622                build_where_with_concurrency(gen, &keys, &concurrency_tokens, *original, 1)?;
623
624            if let Some(filter) = query_filter {
625                let mut idx = where_params.len() + 1;
626                let filter_sql = compile_bool_expr(filter, gen, &mut idx);
627                where_params.extend(collect_bool_expr_values(filter));
628                where_clause = format!("({}) AND ({})", where_clause, filter_sql);
629            }
630
631            let sql = gen.delete(meta.table_name.as_ref(), &where_clause);
632            let rows = conn.execute(&sql, &where_params).await?;
633            if rows == 0 {
634                return Err(EFError::concurrency_conflict(format!(
635                    "delete affected 0 rows on {} (row may have been modified or deleted)",
636                    meta.table_name
637                )));
638            }
639            deleted += 1;
640        }
641        Ok(deleted)
642    }
643}
644
645fn build_where_with_concurrency(
646    gen: &dyn crate::provider::ISqlGenerator,
647    keys: &HashMap<String, DbValue>,
648    concurrency_tokens: &[&PropertyMeta],
649    original: Option<&HashMap<String, DbValue>>,
650    start_param_idx: usize,
651) -> EFResult<(String, Vec<DbValue>)> {
652    let mut where_parts: Vec<String> = keys
653        .keys()
654        .enumerate()
655        .map(|(i, k)| {
656            format!(
657                "{} = {}",
658                gen.quote_identifier(k),
659                gen.parameter_placeholder(start_param_idx + i)
660            )
661        })
662        .collect();
663
664    let mut params: Vec<DbValue> = keys.values().cloned().collect();
665
666    for (next_idx, token) in (start_param_idx + keys.len()..).zip(concurrency_tokens.iter()) {
667        where_parts.push(format!(
668            "{} = {}",
669            gen.quote_identifier(token.column_name.as_ref()),
670            gen.parameter_placeholder(next_idx)
671        ));
672
673        let original_val = original
674            .and_then(|o| o.get(token.field_name.as_ref()))
675            .ok_or_else(|| {
676                EFError::change_tracking(format!(
677                    "missing original concurrency token for '{}'",
678                    token.field_name
679                ))
680            })?;
681        params.push(original_val.clone());
682    }
683
684    Ok((where_parts.join(" AND "), params))
685}
686
687// ---------------------------------------------------------------------------
688// Standalone SQL generation helpers (for use by simplified callers)
689// ---------------------------------------------------------------------------
690
691pub fn generate_insert_sql(
692    provider: &dyn IDatabaseProvider,
693    meta: &EntityTypeMeta,
694    _property_values: &HashMap<String, DbValue>,
695) -> String {
696    let gen = provider.sql_generator();
697    let scalar_props: Vec<_> = meta.mapped_scalar_properties().collect();
698    let columns: Vec<&str> = scalar_props
699        .iter()
700        .map(|p| p.column_name.as_ref())
701        .collect();
702    if columns.is_empty() {
703        return String::new();
704    }
705    gen.insert(meta.table_name.as_ref(), &columns, true)
706}
707
708pub fn generate_update_sql(
709    provider: &dyn IDatabaseProvider,
710    meta: &EntityTypeMeta,
711    property_values: &HashMap<String, DbValue>,
712    primary_key_values: &HashMap<String, DbValue>,
713) -> String {
714    let gen = provider.sql_generator();
715    let set_columns: Vec<&str> = property_values
716        .keys()
717        .filter(|k| !primary_key_values.contains_key(*k))
718        .map(|k| k.as_str())
719        .collect();
720    if set_columns.is_empty() || primary_key_values.is_empty() {
721        return String::new();
722    }
723    let where_parts: Vec<String> = primary_key_values
724        .keys()
725        .enumerate()
726        .map(|(i, k)| {
727            format!(
728                "{} = {}",
729                gen.quote_identifier(k),
730                gen.parameter_placeholder(i + 1)
731            )
732        })
733        .collect();
734    gen.update(
735        meta.table_name.as_ref(),
736        &set_columns,
737        &where_parts.join(" AND "),
738    )
739}
740
741pub fn generate_delete_sql(
742    provider: &dyn IDatabaseProvider,
743    meta: &EntityTypeMeta,
744    primary_key_values: &HashMap<String, DbValue>,
745) -> String {
746    let gen = provider.sql_generator();
747    if primary_key_values.is_empty() {
748        return String::new();
749    }
750    let where_parts: Vec<String> = primary_key_values
751        .keys()
752        .enumerate()
753        .map(|(i, k)| {
754            format!(
755                "{} = {}",
756                gen.quote_identifier(k),
757                gen.parameter_placeholder(i + 1)
758            )
759        })
760        .collect();
761    gen.delete(meta.table_name.as_ref(), &where_parts.join(" AND "))
762}
763
764pub fn collect_insert_params(
765    meta: &EntityTypeMeta,
766    property_values: &HashMap<String, DbValue>,
767) -> Vec<DbValue> {
768    meta.mapped_scalar_properties()
769        .map(|p| {
770            property_values
771                .get(p.field_name.as_ref())
772                .cloned()
773                .unwrap_or(DbValue::Null)
774        })
775        .collect()
776}
777
778pub fn collect_update_params(
779    property_values: &HashMap<String, DbValue>,
780    primary_key_values: &HashMap<String, DbValue>,
781    set_keys: &[String],
782) -> Vec<DbValue> {
783    let mut params: Vec<DbValue> = set_keys
784        .iter()
785        .filter(|k| !primary_key_values.contains_key(*k))
786        .map(|k| property_values.get(k).cloned().unwrap_or(DbValue::Null))
787        .collect();
788    for v in primary_key_values.values() {
789        params.push(v.clone());
790    }
791    params
792}
793
794pub fn collect_delete_params(primary_key_values: &HashMap<String, DbValue>) -> Vec<DbValue> {
795    primary_key_values.values().cloned().collect()
796}