Skip to main content

prax_query/operations/
update.rs

1//! Update operation for modifying existing records.
2
3use std::marker::PhantomData;
4
5use crate::error::QueryResult;
6use crate::filter::{Filter, FilterValue};
7use crate::inputs::WriteOp;
8use crate::nested::NestedWriteOp;
9use crate::traits::{Model, QueryEngine};
10use crate::types::Select;
11
12/// Extract the parent PK value from a where-unique filter when it
13/// equal-matches the model's primary-key column directly.
14///
15/// Returns `None` for any other filter shape (non-PK equals, non-equals
16/// comparators, AND/OR composites, etc.). Nested-write callers turn this
17/// into a clear "where must equal-match the PK" error.
18pub(crate) fn extract_pk_from_filter(filter: &Filter, pk_col: &str) -> Option<FilterValue> {
19    match filter {
20        Filter::Equals(name, value) if name.as_ref() == pk_col => Some(value.clone()),
21        _ => None,
22    }
23}
24
25/// An update operation for modifying existing records.
26///
27/// # Example
28///
29/// ```rust,ignore
30/// let users = client
31///     .user()
32///     .update()
33///     .r#where(user::id::equals(1))
34///     .set("name", "Updated Name")
35///     .exec()
36///     .await?;
37/// ```
38pub struct UpdateOperation<E: QueryEngine, M: Model> {
39    engine: E,
40    filter: Filter,
41    updates: Vec<(String, WriteOp)>,
42    select: Select,
43    /// Queued nested-write ops run after the parent UPDATE inside an
44    /// implicit transaction. Populated by [`UpdateOperation::with`].
45    /// Empty on the fast path (single UPDATE, no transaction wrap).
46    nested: Vec<NestedWriteOp>,
47    _model: PhantomData<M>,
48}
49
50impl<E: QueryEngine, M: Model + crate::row::FromRow> UpdateOperation<E, M> {
51    /// Create a new Update operation.
52    pub fn new(engine: E) -> Self {
53        Self {
54            engine,
55            filter: Filter::None,
56            updates: Vec::new(),
57            select: Select::All,
58            nested: Vec::new(),
59            _model: PhantomData,
60        }
61    }
62
63    /// Add a filter condition.
64    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
65        let new_filter = filter.into();
66        self.filter = self.filter.and_then(new_filter);
67        self
68    }
69
70    /// Set a column to a new value.
71    pub fn set(mut self, column: impl Into<String>, value: impl Into<FilterValue>) -> Self {
72        self.updates
73            .push((column.into(), WriteOp::Set(value.into())));
74        self
75    }
76
77    /// Set multiple columns from an iterator.
78    pub fn set_many(
79        mut self,
80        values: impl IntoIterator<Item = (impl Into<String>, impl Into<FilterValue>)>,
81    ) -> Self {
82        for (col, val) in values {
83            self.updates.push((col.into(), WriteOp::Set(val.into())));
84        }
85        self
86    }
87
88    /// Increment a numeric column.
89    pub fn increment(mut self, column: impl Into<String>, amount: i64) -> Self {
90        self.updates
91            .push((column.into(), WriteOp::Increment(FilterValue::Int(amount))));
92        self
93    }
94
95    /// Apply a column-keyed [`WriteOp`].
96    ///
97    /// Used by `with_update_input` (and tests) to push an arbitrary
98    /// scalar atomic operator onto the update list. The DSL surface
99    /// for these operators is the `*FieldUpdate` wrappers in
100    /// [`crate::inputs::scalar_update`].
101    pub fn set_op(mut self, column: impl Into<String>, op: WriteOp) -> Self {
102        self.updates.push((column.into(), op));
103        self
104    }
105
106    /// Select specific fields to return.
107    pub fn select(mut self, select: impl Into<Select>) -> Self {
108        self.select = select.into();
109        self
110    }
111
112    /// Build the SQL query.
113    pub fn build_sql(
114        &self,
115        dialect: &dyn crate::dialect::SqlDialect,
116    ) -> (String, Vec<FilterValue>) {
117        let mut sql = String::new();
118        let mut params = Vec::new();
119        let mut param_idx = 1;
120
121        // UPDATE clause
122        sql.push_str("UPDATE ");
123        sql.push_str(M::TABLE_NAME);
124
125        // SET clause
126        sql.push_str(" SET ");
127        let set_parts: Vec<String> = self
128            .updates
129            .iter()
130            .map(|(col, op)| {
131                let placeholder = dialect.placeholder(param_idx);
132                let (fragment, value) = op.to_set_fragment(col, &placeholder);
133                if let Some(v) = value {
134                    params.push(v);
135                    param_idx += 1;
136                }
137                fragment
138            })
139            .collect();
140        sql.push_str(&set_parts.join(", "));
141
142        // WHERE clause
143        if !self.filter.is_none() {
144            let (where_sql, where_params) = self.filter.to_sql(param_idx - 1, dialect);
145            sql.push_str(" WHERE ");
146            sql.push_str(&where_sql);
147            params.extend(where_params);
148        }
149
150        // RETURNING clause
151        sql.push_str(&dialect.returning_clause(&self.select.to_sql()));
152
153        (sql, params)
154    }
155
156    /// Queue a nested write to run alongside this update.
157    ///
158    /// The parent `UPDATE` and every queued nested op execute inside a
159    /// single implicit transaction — any failure rolls back the parent
160    /// UPDATE too.
161    ///
162    /// Nested writes inside `update!` currently require the `where:`
163    /// filter to equal-match the primary-key column. Non-PK unique
164    /// columns (e.g. `where: { email: "..." }`) error at exec time
165    /// with a clear diagnostic. Lifting this restriction needs a
166    /// SELECT-then-update pattern to capture the row's PK — deferred.
167    pub fn with(mut self, nw: NestedWriteOp) -> Self
168    where
169        E: crate::capabilities::SupportsNestedWrites,
170    {
171        self.nested.push(nw);
172        self
173    }
174
175    /// Error for executing an update with an empty SET list — building
176    /// would emit invalid SQL (`UPDATE t SET  WHERE ... RETURNING *`),
177    /// and `build_sql` is infallible, so the execution surface rejects it.
178    fn empty_updates_error() -> crate::error::QueryError {
179        crate::error::QueryError::invalid_input(
180            "updates",
181            "update requires at least one SET value — an empty SET list would \
182             produce invalid SQL (`UPDATE ... SET  WHERE ...`)",
183        )
184        .with_help(format!(
185            "add a field update via `.set(...)`, `.set_many(...)`, `.increment(...)`, \
186             `.set_op(...)`, or a typed `UpdateInput` before executing the update on `{table}`.",
187            table = M::TABLE_NAME,
188        ))
189    }
190
191    /// Execute the update and return modified records.
192    pub async fn exec(self) -> QueryResult<Vec<M>>
193    where
194        M: Send + 'static,
195    {
196        if self.updates.is_empty() {
197            return Err(Self::empty_updates_error());
198        }
199
200        // Fast path: no nested writes — single UPDATE statement.
201        if self.nested.is_empty() {
202            let dialect = self.engine.dialect();
203            let (sql, params) = self.build_sql(dialect);
204            return self.engine.execute_update::<M>(&sql, params).await;
205        }
206
207        // Slow path: extract the parent PK from the `where` filter, then
208        // run the UPDATE + queued nested ops inside a transaction.
209        let parent_pk =
210            extract_pk_from_filter(&self.filter, M::PRIMARY_KEY[0]).ok_or_else(|| {
211                crate::error::QueryError::invalid_input(
212                    "where",
213                    "nested writes inside `update!` require the `where:` clause to equal-match \
214                     the primary-key column",
215                )
216                .with_help(format!(
217                    "expected `where: {{ {pk}: <value> }}` on `{table}` — non-PK unique \
218                     columns are not yet supported for nested writes inside update!. \
219                     Lift this restriction by running the nested ops in a separate operation \
220                     after looking up the row's PK.",
221                    pk = M::PRIMARY_KEY[0],
222                    table = M::TABLE_NAME,
223                ))
224            })?;
225
226        let UpdateOperation {
227            engine,
228            filter,
229            updates,
230            select,
231            nested,
232            _model,
233        } = self;
234
235        engine
236            .transaction(move |tx| async move {
237                let dialect = tx.dialect();
238                let (sql, params) = Self::build_sql_parts(&filter, &updates, &select, dialect);
239                let parent: Vec<M> = tx.execute_update::<M>(&sql, params).await?;
240
241                // Batch consecutive Connect ops with the same target.
242                let mut idx = 0;
243                while idx < nested.len() {
244                    if let NestedWriteOp::Connect {
245                        target_table: run_table,
246                        foreign_key: run_fk,
247                        target_pk: run_target_pk,
248                        ..
249                    } = &nested[idx]
250                    {
251                        let run_table = *run_table;
252                        let run_fk = *run_fk;
253                        let run_target_pk = *run_target_pk;
254                        let mut end = idx + 1;
255                        while end < nested.len() {
256                            match &nested[end] {
257                                NestedWriteOp::Connect {
258                                    target_table,
259                                    foreign_key,
260                                    target_pk,
261                                    ..
262                                } if *target_table == run_table
263                                    && *foreign_key == run_fk
264                                    && *target_pk == run_target_pk =>
265                                {
266                                    end += 1;
267                                }
268                                _ => break,
269                            }
270                        }
271
272                        if end - idx == 1 {
273                            let op = nested[idx].clone();
274                            op.execute(&tx, &parent_pk).await?;
275                        } else {
276                            let expected = (end - idx) as u64;
277                            let mut pks: Vec<FilterValue> = Vec::with_capacity(end - idx + 1);
278                            pks.push(parent_pk.clone());
279                            for op in &nested[idx..end] {
280                                if let NestedWriteOp::Connect { pk, .. } = op {
281                                    pks.push(pk.clone());
282                                }
283                            }
284                            let placeholders: Vec<String> =
285                                (2..=pks.len()).map(|i| dialect.placeholder(i)).collect();
286                            let sql = format!(
287                                "UPDATE {} SET {} = {} WHERE {} IN ({})",
288                                dialect.quote_ident(run_table),
289                                dialect.quote_ident(run_fk),
290                                dialect.placeholder(1),
291                                dialect.quote_ident(run_target_pk),
292                                placeholders.join(", "),
293                            );
294                            let affected = tx.execute_raw(&sql, pks).await?;
295                            if affected != expected {
296                                return Err(crate::error::QueryError::not_found(run_table)
297                                    .with_context("Nested Connect batch")
298                                    .with_help(format!(
299                                        "Expected {} matching rows but UPDATE affected {}",
300                                        expected, affected
301                                    )));
302                            }
303                        }
304                        idx = end;
305                    } else {
306                        let op = nested[idx].clone();
307                        op.execute(&tx, &parent_pk).await?;
308                        idx += 1;
309                    }
310                }
311                Ok(parent)
312            })
313            .await
314    }
315
316    /// Free-function form of [`Self::build_sql`] — takes the pieces by
317    /// reference so the `exec` path can reuse it after destructuring
318    /// `self` to move the captured state into the transaction closure.
319    fn build_sql_parts(
320        filter: &Filter,
321        updates: &[(String, WriteOp)],
322        select: &Select,
323        dialect: &dyn crate::dialect::SqlDialect,
324    ) -> (String, Vec<FilterValue>) {
325        let mut sql = String::new();
326        let mut params = Vec::new();
327        let mut param_idx = 1;
328
329        sql.push_str("UPDATE ");
330        sql.push_str(M::TABLE_NAME);
331
332        sql.push_str(" SET ");
333        let set_parts: Vec<String> = updates
334            .iter()
335            .map(|(col, op)| {
336                let placeholder = dialect.placeholder(param_idx);
337                let (fragment, value) = op.to_set_fragment(col, &placeholder);
338                if let Some(v) = value {
339                    params.push(v);
340                    param_idx += 1;
341                }
342                fragment
343            })
344            .collect();
345        sql.push_str(&set_parts.join(", "));
346
347        if !filter.is_none() {
348            let (where_sql, where_params) = filter.to_sql(param_idx - 1, dialect);
349            sql.push_str(" WHERE ");
350            sql.push_str(&where_sql);
351            params.extend(where_params);
352        }
353
354        sql.push_str(&dialect.returning_clause(&select.to_sql()));
355
356        (sql, params)
357    }
358
359    /// Execute the update and return the first modified record.
360    pub async fn exec_one(self) -> QueryResult<M>
361    where
362        M: Send + 'static,
363    {
364        if self.updates.is_empty() {
365            return Err(Self::empty_updates_error());
366        }
367
368        let dialect = self.engine.dialect();
369        let (sql, params) = self.build_sql(dialect);
370        self.engine.query_one::<M>(&sql, params).await
371    }
372
373    /// Apply a typed `WhereUniqueInput`. AND-composes with any
374    /// previously set filter so callers can combine the unique key
375    /// with side conditions when they need to.
376    pub fn with_where_input<W: crate::inputs::WhereUniqueInput<Model = M>>(mut self, w: W) -> Self {
377        let f = w.into_ir();
378        self.filter = self.filter.and_then(f);
379        self
380    }
381
382    /// Apply a typed `SelectInput`.
383    pub fn with_select_input<S: crate::inputs::SelectInput<Model = M>>(mut self, s: S) -> Self {
384        self.select = s.into_ir();
385        self
386    }
387
388    /// Apply a typed `UpdateInput`.
389    ///
390    /// The input's `into_ir` produces a `Vec<(column, WriteOp)>` —
391    /// each entry is appended to the operation's SET list. Atomic
392    /// operators (`Increment`/`Decrement`/`Multiply`/`Divide`) emit
393    /// `col = col <op> $n` in the resulting SQL; `Set` emits
394    /// `col = $n`; `Unset` emits `col = NULL` with no placeholder.
395    pub fn with_update_input<I>(mut self, input: I) -> Self
396    where
397        I: crate::inputs::UpdateInput<Model = M, Data = crate::inputs::UpdatePayload>,
398    {
399        let data: crate::inputs::UpdatePayload = input.into_ir();
400        for (col, op) in data {
401            self.updates.push((col, op));
402        }
403        self
404    }
405
406    /// Doc-hidden accessor for the current filter.
407    #[doc(hidden)]
408    pub fn filter_for_test(&self) -> &Filter {
409        &self.filter
410    }
411}
412
413/// Update many records at once.
414pub struct UpdateManyOperation<E: QueryEngine, M: Model> {
415    engine: E,
416    filter: Filter,
417    updates: Vec<(String, WriteOp)>,
418    _model: PhantomData<M>,
419}
420
421impl<E: QueryEngine, M: Model> UpdateManyOperation<E, M> {
422    /// Create a new UpdateMany operation.
423    pub fn new(engine: E) -> Self {
424        Self {
425            engine,
426            filter: Filter::None,
427            updates: Vec::new(),
428            _model: PhantomData,
429        }
430    }
431
432    /// Add a filter condition.
433    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
434        let new_filter = filter.into();
435        self.filter = self.filter.and_then(new_filter);
436        self
437    }
438
439    /// Set a column to a new value.
440    pub fn set(mut self, column: impl Into<String>, value: impl Into<FilterValue>) -> Self {
441        self.updates
442            .push((column.into(), WriteOp::Set(value.into())));
443        self
444    }
445
446    /// Apply a column-keyed [`WriteOp`].
447    pub fn set_op(mut self, column: impl Into<String>, op: WriteOp) -> Self {
448        self.updates.push((column.into(), op));
449        self
450    }
451
452    /// Apply a typed `WhereInput`. AND-composes with the existing filter.
453    pub fn with_where_input<W: crate::inputs::WhereInput<Model = M>>(mut self, w: W) -> Self {
454        let f = w.into_ir();
455        self.filter = self.filter.and_then(f);
456        self
457    }
458
459    /// Apply a typed `UpdateInput`.
460    ///
461    /// See [`UpdateOperation::with_update_input`] for the lowering
462    /// semantics — the only difference here is that `update_many` does
463    /// not return rows.
464    pub fn with_update_input<I>(mut self, input: I) -> Self
465    where
466        I: crate::inputs::UpdateInput<Model = M, Data = crate::inputs::UpdatePayload>,
467    {
468        let data: crate::inputs::UpdatePayload = input.into_ir();
469        for (col, op) in data {
470            self.updates.push((col, op));
471        }
472        self
473    }
474
475    /// Build the SQL query.
476    pub fn build_sql(
477        &self,
478        dialect: &dyn crate::dialect::SqlDialect,
479    ) -> (String, Vec<FilterValue>) {
480        let mut sql = String::new();
481        let mut params = Vec::new();
482        let mut param_idx = 1;
483
484        // UPDATE clause
485        sql.push_str("UPDATE ");
486        sql.push_str(M::TABLE_NAME);
487
488        // SET clause
489        sql.push_str(" SET ");
490        let set_parts: Vec<String> = self
491            .updates
492            .iter()
493            .map(|(col, op)| {
494                let placeholder = dialect.placeholder(param_idx);
495                let (fragment, value) = op.to_set_fragment(col, &placeholder);
496                if let Some(v) = value {
497                    params.push(v);
498                    param_idx += 1;
499                }
500                fragment
501            })
502            .collect();
503        sql.push_str(&set_parts.join(", "));
504
505        // WHERE clause
506        if !self.filter.is_none() {
507            let (where_sql, where_params) = self.filter.to_sql(param_idx - 1, dialect);
508            sql.push_str(" WHERE ");
509            sql.push_str(&where_sql);
510            params.extend(where_params);
511        }
512
513        (sql, params)
514    }
515
516    /// Execute the update and return the count of modified records.
517    pub async fn exec(self) -> QueryResult<u64> {
518        let dialect = self.engine.dialect();
519        let (sql, params) = self.build_sql(dialect);
520        self.engine.execute_raw(&sql, params).await
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::error::QueryError;
528    use crate::types::Select;
529
530    struct TestModel;
531
532    impl Model for TestModel {
533        const MODEL_NAME: &'static str = "TestModel";
534        const TABLE_NAME: &'static str = "test_models";
535        const PRIMARY_KEY: &'static [&'static str] = &["id"];
536        const COLUMNS: &'static [&'static str] = &["id", "name", "email"];
537    }
538
539    impl crate::row::FromRow for TestModel {
540        fn from_row(_row: &impl crate::row::RowRef) -> Result<Self, crate::row::RowError> {
541            Ok(TestModel)
542        }
543    }
544
545    #[derive(Clone)]
546    struct MockEngine {
547        return_count: u64,
548    }
549
550    impl MockEngine {
551        fn new() -> Self {
552            Self { return_count: 0 }
553        }
554
555        fn with_count(count: u64) -> Self {
556            Self {
557                return_count: count,
558            }
559        }
560    }
561
562    impl QueryEngine for MockEngine {
563        fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
564            &crate::dialect::Postgres
565        }
566
567        fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
568            &self,
569            _sql: &str,
570            _params: Vec<FilterValue>,
571        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
572            Box::pin(async { Ok(Vec::new()) })
573        }
574
575        fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
576            &self,
577            _sql: &str,
578            _params: Vec<FilterValue>,
579        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
580            Box::pin(async { Err(QueryError::not_found("test")) })
581        }
582
583        fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
584            &self,
585            _sql: &str,
586            _params: Vec<FilterValue>,
587        ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
588            Box::pin(async { Ok(None) })
589        }
590
591        fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
592            &self,
593            _sql: &str,
594            _params: Vec<FilterValue>,
595        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
596            Box::pin(async { Err(QueryError::not_found("test")) })
597        }
598
599        fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
600            &self,
601            _sql: &str,
602            _params: Vec<FilterValue>,
603        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
604            Box::pin(async { Ok(Vec::new()) })
605        }
606
607        fn execute_delete(
608            &self,
609            _sql: &str,
610            _params: Vec<FilterValue>,
611        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
612            Box::pin(async { Ok(0) })
613        }
614
615        fn execute_raw(
616            &self,
617            _sql: &str,
618            _params: Vec<FilterValue>,
619        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
620            let count = self.return_count;
621            Box::pin(async move { Ok(count) })
622        }
623
624        fn count(
625            &self,
626            _sql: &str,
627            _params: Vec<FilterValue>,
628        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
629            Box::pin(async { Ok(0) })
630        }
631    }
632
633    // ========== UpdateOperation Tests ==========
634
635    #[test]
636    fn test_update_new() {
637        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new());
638        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
639
640        assert!(sql.contains("UPDATE test_models SET"));
641        assert!(sql.contains("RETURNING *"));
642        assert!(params.is_empty());
643    }
644
645    #[test]
646    fn test_update_basic() {
647        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
648            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
649            .set("name", "Updated");
650
651        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
652
653        assert!(sql.contains("UPDATE test_models SET"));
654        assert!(sql.contains("name = $1"));
655        assert!(sql.contains("WHERE"));
656        assert!(sql.contains("RETURNING *"));
657        assert_eq!(params.len(), 2);
658    }
659
660    #[test]
661    fn test_update_many_fields() {
662        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
663            .set("name", "Updated")
664            .set("email", "updated@example.com");
665
666        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
667
668        assert!(sql.contains("name = $1"));
669        assert!(sql.contains("email = $2"));
670        assert_eq!(params.len(), 2);
671    }
672
673    #[test]
674    fn test_update_with_set_many() {
675        let updates = vec![
676            ("name", FilterValue::String("Alice".to_string())),
677            ("email", FilterValue::String("alice@test.com".to_string())),
678            ("age", FilterValue::Int(30)),
679        ];
680        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new()).set_many(updates);
681
682        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
683
684        assert!(sql.contains("name = $1"));
685        assert!(sql.contains("email = $2"));
686        assert!(sql.contains("age = $3"));
687        assert_eq!(params.len(), 3);
688    }
689
690    #[test]
691    fn test_update_increment() {
692        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
693            .increment("counter", 5);
694
695        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
696
697        // `increment` now lowers to a true `col = col + $n` atomic
698        // operator (the prior implementation collapsed it to a plain
699        // `set`, which was a documented bug).
700        assert!(
701            sql.contains("counter = counter + $1"),
702            "expected `counter = counter + $1`, got: {sql}"
703        );
704        assert_eq!(params.len(), 1);
705        assert_eq!(params[0], FilterValue::Int(5));
706    }
707
708    #[test]
709    fn test_update_with_select() {
710        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
711            .set("name", "Updated")
712            .select(Select::fields(["id", "name"]));
713
714        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
715
716        assert!(sql.contains("RETURNING id, name"));
717    }
718
719    #[test]
720    fn test_update_with_complex_filter() {
721        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
722            .r#where(Filter::Equals(
723                "status".into(),
724                FilterValue::String("active".to_string()),
725            ))
726            .r#where(Filter::Gt("age".into(), FilterValue::Int(18)))
727            .set("verified", FilterValue::Bool(true));
728
729        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
730
731        assert!(sql.contains("WHERE"));
732        assert!(sql.contains("AND"));
733        assert_eq!(params.len(), 3); // 1 set + 2 where
734    }
735
736    #[test]
737    fn test_update_without_filter() {
738        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
739            .set("status", "updated");
740
741        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
742
743        // Should not have WHERE clause
744        assert!(!sql.contains("WHERE"));
745        assert!(sql.contains("UPDATE test_models SET"));
746    }
747
748    #[test]
749    fn test_update_with_null_value() {
750        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
751            .set("deleted_at", FilterValue::Null);
752
753        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
754
755        assert!(sql.contains("deleted_at = $1"));
756        assert_eq!(params.len(), 1);
757        assert_eq!(params[0], FilterValue::Null);
758    }
759
760    #[test]
761    fn test_update_with_boolean() {
762        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
763            .set("active", FilterValue::Bool(true))
764            .set("verified", FilterValue::Bool(false));
765
766        let (_sql, params) = op.build_sql(&crate::dialect::Postgres);
767
768        assert_eq!(params.len(), 2);
769        assert_eq!(params[0], FilterValue::Bool(true));
770        assert_eq!(params[1], FilterValue::Bool(false));
771    }
772
773    #[tokio::test]
774    async fn test_update_exec() {
775        let op =
776            UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new()).set("name", "Updated");
777
778        let result = op.exec().await;
779        assert!(result.is_ok());
780        assert!(result.unwrap().is_empty());
781    }
782
783    #[tokio::test]
784    async fn test_update_exec_one() {
785        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
786            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
787            .set("name", "Updated");
788
789        let result = op.exec_one().await;
790        assert!(result.is_err()); // MockEngine returns not_found
791    }
792
793    #[tokio::test]
794    async fn test_update_exec_rejects_empty_updates() {
795        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
796            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
797
798        let err = op
799            .exec()
800            .await
801            .err()
802            .expect("empty SET list must be rejected before building SQL");
803        let msg = err.to_string();
804        assert!(
805            msg.contains("at least one SET value"),
806            "expected empty-update diagnostic, got: {msg}"
807        );
808    }
809
810    #[tokio::test]
811    async fn test_update_exec_one_rejects_empty_updates() {
812        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new());
813
814        let err = op
815            .exec_one()
816            .await
817            .err()
818            .expect("empty SET list must be rejected before building SQL");
819        let msg = err.to_string();
820        // The guard must fire before the engine — MockEngine::query_one
821        // would otherwise return its own `not_found` error.
822        assert!(
823            msg.contains("at least one SET value"),
824            "expected empty-update diagnostic, got: {msg}"
825        );
826    }
827
828    // ========== UpdateManyOperation Tests ==========
829
830    #[test]
831    fn test_update_many_new() {
832        let op = UpdateManyOperation::<MockEngine, TestModel>::new(MockEngine::new());
833        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
834
835        assert!(sql.contains("UPDATE test_models SET"));
836        assert!(!sql.contains("RETURNING")); // UpdateMany doesn't return records
837        assert!(params.is_empty());
838    }
839
840    #[test]
841    fn test_update_many_basic() {
842        let op = UpdateManyOperation::<MockEngine, TestModel>::new(MockEngine::new())
843            .r#where(Filter::In(
844                "id".into(),
845                vec![
846                    FilterValue::Int(1),
847                    FilterValue::Int(2),
848                    FilterValue::Int(3),
849                ],
850            ))
851            .set("status", "processed");
852
853        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
854
855        assert!(sql.contains("UPDATE test_models SET"));
856        assert!(sql.contains("status = $1"));
857        assert!(sql.contains("WHERE"));
858        assert!(sql.contains("IN"));
859        assert_eq!(params.len(), 4); // 1 set + 3 IN values
860    }
861
862    #[test]
863    fn test_update_many_with_multiple_conditions() {
864        let op = UpdateManyOperation::<MockEngine, TestModel>::new(MockEngine::new())
865            .r#where(Filter::Equals(
866                "department".into(),
867                FilterValue::String("engineering".to_string()),
868            ))
869            .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)))
870            .set("reviewed", FilterValue::Bool(true));
871
872        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
873
874        assert!(sql.contains("AND"));
875        assert_eq!(params.len(), 3);
876    }
877
878    #[test]
879    fn test_update_many_without_where() {
880        let op = UpdateManyOperation::<MockEngine, TestModel>::new(MockEngine::new())
881            .set("reset_password", FilterValue::Bool(true));
882
883        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
884
885        assert!(!sql.contains("WHERE"));
886    }
887
888    #[tokio::test]
889    async fn test_update_many_exec() {
890        let op = UpdateManyOperation::<MockEngine, TestModel>::new(MockEngine::with_count(5))
891            .set("status", "updated");
892
893        let result = op.exec().await;
894        assert!(result.is_ok());
895        assert_eq!(result.unwrap(), 5);
896    }
897
898    // ========== SQL Generation Edge Cases ==========
899
900    #[test]
901    fn test_update_param_ordering() {
902        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
903            .set("field1", "value1")
904            .set("field2", "value2")
905            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
906
907        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
908
909        // SET params come first, then WHERE params
910        assert!(sql.contains("field1 = $1"));
911        assert!(sql.contains("field2 = $2"));
912        assert!(sql.contains(r#""id" = $3"#));
913        assert_eq!(params.len(), 3);
914    }
915
916    #[test]
917    fn test_update_many_param_ordering() {
918        let op = UpdateManyOperation::<MockEngine, TestModel>::new(MockEngine::new())
919            .set("field1", "value1")
920            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
921
922        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
923
924        assert!(sql.contains("field1 = $1"));
925        assert!(sql.contains(r#""id" = $2"#));
926        assert_eq!(params.len(), 2);
927    }
928
929    #[test]
930    fn test_update_with_float_value() {
931        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
932            .set("price", FilterValue::Float(99.99));
933
934        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
935
936        assert!(sql.contains("price = $1"));
937        assert_eq!(params.len(), 1);
938    }
939
940    #[test]
941    fn test_update_with_json_value() {
942        let json_value = serde_json::json!({"key": "value"});
943        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
944            .set("metadata", FilterValue::Json(json_value.clone()));
945
946        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
947
948        assert!(sql.contains("metadata = $1"));
949        assert_eq!(params[0], FilterValue::Json(json_value));
950    }
951
952    // ========== Phase 5a: typed-input wiring ==========
953
954    /// Mock `UpdateInput` used by the `with_update_input` tests. The
955    /// codegen-emitted equivalent isn't available inside `prax-query`,
956    /// so we hand-roll the trait impl against `TestModel`.
957    struct MockUpdateInput(Vec<(String, WriteOp)>);
958
959    impl crate::inputs::UpdateInput for MockUpdateInput {
960        type Model = TestModel;
961        type Data = crate::inputs::UpdatePayload;
962        fn into_ir(self) -> Self::Data {
963            self.0
964        }
965    }
966
967    #[test]
968    fn with_update_input_appends_set_ops() {
969        let input = MockUpdateInput(vec![
970            (
971                "name".into(),
972                WriteOp::Set(FilterValue::String("Bob".into())),
973            ),
974            ("age".into(), WriteOp::Increment(FilterValue::Int(1))),
975        ]);
976
977        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
978            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
979            .with_update_input(input);
980
981        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
982
983        // `Set` emits the plain `col = $n` form; `Increment` emits the
984        // atomic-operator `col = col + $n` fragment.
985        assert!(sql.contains("name = $1"), "got: {sql}");
986        assert!(sql.contains("age = age + $2"), "got: {sql}");
987        // 2 SET params + 1 WHERE param.
988        assert_eq!(params.len(), 3);
989        assert_eq!(params[0], FilterValue::String("Bob".into()));
990        assert_eq!(params[1], FilterValue::Int(1));
991    }
992
993    #[test]
994    fn with_update_input_unset_emits_null_no_param() {
995        let input = MockUpdateInput(vec![("nickname".into(), WriteOp::Unset)]);
996
997        let op = UpdateOperation::<MockEngine, TestModel>::new(MockEngine::new())
998            .with_update_input(input);
999
1000        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1001
1002        assert!(sql.contains("nickname = NULL"), "got: {sql}");
1003        // Unset emits no placeholder, so no parameter is pushed.
1004        assert!(params.is_empty(), "expected no params, got: {params:?}");
1005    }
1006
1007    #[test]
1008    fn update_many_with_update_input_appends() {
1009        let input = MockUpdateInput(vec![(
1010            "name".into(),
1011            WriteOp::Set(FilterValue::String("Bob".into())),
1012        )]);
1013
1014        let op = UpdateManyOperation::<MockEngine, TestModel>::new(MockEngine::new())
1015            .r#where(Filter::Equals("active".into(), FilterValue::Bool(true)))
1016            .with_update_input(input);
1017
1018        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1019
1020        assert!(sql.contains("UPDATE test_models SET"));
1021        assert!(sql.contains("name = $1"), "got: {sql}");
1022        assert!(sql.contains("WHERE"));
1023        assert_eq!(params.len(), 2);
1024    }
1025
1026    // ========== Phase 5c: nested-write wiring on update! ==========
1027
1028    use std::sync::{Arc, Mutex};
1029
1030    type StatementLog = Arc<Mutex<Vec<(String, Vec<FilterValue>)>>>;
1031
1032    /// Recording engine mirroring `nested.rs::tests::RecordingEngine`.
1033    /// Captures every (sql, params) on `execute_raw`, returns the next
1034    /// entry of `affected` (or 1 as fallback), and `execute_update`
1035    /// records the parent UPDATE too while returning an empty row vec.
1036    #[derive(Clone)]
1037    struct RecordingEngine {
1038        recorded: StatementLog,
1039        affected: Arc<Mutex<Vec<u64>>>,
1040    }
1041
1042    impl RecordingEngine {
1043        fn new() -> Self {
1044            Self {
1045                recorded: Arc::new(Mutex::new(Vec::new())),
1046                affected: Arc::new(Mutex::new(Vec::new())),
1047            }
1048        }
1049
1050        fn statements(&self) -> Vec<(String, Vec<FilterValue>)> {
1051            self.recorded.lock().unwrap().clone()
1052        }
1053    }
1054
1055    impl crate::capabilities::SupportsNestedWrites for RecordingEngine {}
1056
1057    impl QueryEngine for RecordingEngine {
1058        fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
1059            &crate::dialect::Postgres
1060        }
1061
1062        fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
1063            &self,
1064            _sql: &str,
1065            _params: Vec<FilterValue>,
1066        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
1067            Box::pin(async { Ok(Vec::new()) })
1068        }
1069
1070        fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
1071            &self,
1072            _sql: &str,
1073            _params: Vec<FilterValue>,
1074        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
1075            Box::pin(async { Err(QueryError::not_found("test")) })
1076        }
1077
1078        fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
1079            &self,
1080            _sql: &str,
1081            _params: Vec<FilterValue>,
1082        ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
1083            Box::pin(async { Ok(None) })
1084        }
1085
1086        fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
1087            &self,
1088            _sql: &str,
1089            _params: Vec<FilterValue>,
1090        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
1091            Box::pin(async { Err(QueryError::not_found("test")) })
1092        }
1093
1094        fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
1095            &self,
1096            sql: &str,
1097            params: Vec<FilterValue>,
1098        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
1099            let recorded = self.recorded.clone();
1100            let sql = sql.to_string();
1101            Box::pin(async move {
1102                recorded.lock().unwrap().push((sql, params));
1103                Ok(Vec::new())
1104            })
1105        }
1106
1107        fn execute_delete(
1108            &self,
1109            _sql: &str,
1110            _params: Vec<FilterValue>,
1111        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1112            Box::pin(async { Ok(0) })
1113        }
1114
1115        fn execute_raw(
1116            &self,
1117            sql: &str,
1118            params: Vec<FilterValue>,
1119        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1120            let recorded = self.recorded.clone();
1121            let affected = self.affected.clone();
1122            let sql_string = sql.to_string();
1123            let default = if sql.contains(" IN (") {
1124                (params.len() as u64).saturating_sub(1)
1125            } else {
1126                1
1127            };
1128            Box::pin(async move {
1129                recorded.lock().unwrap().push((sql_string, params));
1130                Ok(affected.lock().unwrap().pop().unwrap_or(default))
1131            })
1132        }
1133
1134        fn count(
1135            &self,
1136            _sql: &str,
1137            _params: Vec<FilterValue>,
1138        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1139            Box::pin(async { Ok(0) })
1140        }
1141    }
1142
1143    #[tokio::test]
1144    async fn update_with_nested_create_runs_parent_then_child_insert() {
1145        let engine = RecordingEngine::new();
1146        let op = UpdateOperation::<RecordingEngine, TestModel>::new(engine.clone())
1147            .r#where(Filter::Equals("id".into(), FilterValue::Int(7)))
1148            .set("name", "Renamed")
1149            .with(NestedWriteOp::Create {
1150                relation: "posts",
1151                target_table: "posts",
1152                foreign_key: "author_id",
1153                payload: vec![vec![("title".into(), FilterValue::String("p1".into()))]],
1154            });
1155
1156        let _ = op.exec().await.expect("update + nested create");
1157
1158        let stmts = engine.statements();
1159        assert_eq!(
1160            stmts.len(),
1161            2,
1162            "parent UPDATE + nested child INSERT; got {stmts:#?}"
1163        );
1164        assert!(
1165            stmts[0].0.contains("UPDATE test_models"),
1166            "got: {}",
1167            stmts[0].0
1168        );
1169        assert!(stmts[1].0.contains("INSERT INTO"), "got: {}", stmts[1].0);
1170        assert!(stmts[1].0.contains("posts"), "got: {}", stmts[1].0);
1171        assert!(stmts[1].0.contains("author_id"), "got: {}", stmts[1].0);
1172    }
1173
1174    #[tokio::test]
1175    async fn update_with_nested_disconnect_emits_set_null_update() {
1176        let engine = RecordingEngine::new();
1177        let op = UpdateOperation::<RecordingEngine, TestModel>::new(engine.clone())
1178            .r#where(Filter::Equals("id".into(), FilterValue::Int(7)))
1179            .set("name", "Renamed")
1180            .with(NestedWriteOp::Disconnect {
1181                relation: "posts",
1182                target_table: "posts",
1183                foreign_key: "author_id",
1184                target_pk: "id",
1185                pk: FilterValue::Int(42),
1186            });
1187
1188        let _ = op.exec().await.expect("update + nested disconnect");
1189
1190        let stmts = engine.statements();
1191        assert_eq!(stmts.len(), 2, "got {stmts:#?}");
1192        assert!(
1193            stmts[0].0.contains("UPDATE test_models"),
1194            "got: {}",
1195            stmts[0].0
1196        );
1197        let (sql, params) = &stmts[1];
1198        assert!(sql.contains("UPDATE"), "got: {sql}");
1199        assert!(sql.contains("posts"), "got: {sql}");
1200        assert!(sql.contains("author_id"), "got: {sql}");
1201        assert!(sql.contains("NULL"), "got: {sql}");
1202        assert_eq!(params, &vec![FilterValue::Int(42)]);
1203    }
1204
1205    #[tokio::test]
1206    async fn update_nested_requires_pk_in_where_filter() {
1207        let engine = RecordingEngine::new();
1208        // `email` is not the PK column for TestModel — the executor must
1209        // refuse the nested-write path with a clear diagnostic.
1210        let op = UpdateOperation::<RecordingEngine, TestModel>::new(engine.clone())
1211            .r#where(Filter::Equals(
1212                "email".into(),
1213                FilterValue::String("a@x.com".into()),
1214            ))
1215            .set("name", "Renamed")
1216            .with(NestedWriteOp::Disconnect {
1217                relation: "posts",
1218                target_table: "posts",
1219                foreign_key: "author_id",
1220                target_pk: "id",
1221                pk: FilterValue::Int(42),
1222            });
1223
1224        let result = op.exec().await;
1225        let err = result.err().expect("non-PK where must error");
1226        let msg = err.to_string();
1227        assert!(
1228            msg.contains("primary-key column") || msg.contains("primary key"),
1229            "expected PK-required diagnostic, got: {msg}"
1230        );
1231        // Nothing should have been emitted to the engine.
1232        assert!(
1233            engine.statements().is_empty(),
1234            "no SQL should run: {:#?}",
1235            engine.statements()
1236        );
1237    }
1238}