1use 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, ModelWithPk, QueryEngine};
10use crate::types::Select;
11
12pub struct UpsertOperation<E: QueryEngine, M: Model> {
27 engine: E,
28 filter: Filter,
29 create_columns: Vec<String>,
30 create_values: Vec<FilterValue>,
31 update_columns: Vec<String>,
32 update_values: Vec<FilterValue>,
33 update_ops: Vec<(String, WriteOp)>,
39 conflict_columns: Vec<String>,
40 select: Select,
41 create_nested: Vec<NestedWriteOp>,
44 update_nested: Vec<NestedWriteOp>,
47 _model: PhantomData<M>,
48}
49
50impl<E: QueryEngine, M: Model + crate::row::FromRow> UpsertOperation<E, M> {
51 pub fn new(engine: E) -> Self {
53 Self {
54 engine,
55 filter: Filter::None,
56 create_columns: Vec::new(),
57 create_values: Vec::new(),
58 update_columns: Vec::new(),
59 update_values: Vec::new(),
60 update_ops: Vec::new(),
61 conflict_columns: Vec::new(),
62 select: Select::All,
63 create_nested: Vec::new(),
64 update_nested: Vec::new(),
65 _model: PhantomData,
66 }
67 }
68
69 pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
82 self.filter = filter.into();
83 self
84 }
85
86 pub fn on_conflict(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
91 self.conflict_columns = columns.into_iter().map(Into::into).collect();
92 self
93 }
94
95 pub fn create(
97 mut self,
98 values: impl IntoIterator<Item = (impl Into<String>, impl Into<FilterValue>)>,
99 ) -> Self {
100 for (col, val) in values {
101 self.create_columns.push(col.into());
102 self.create_values.push(val.into());
103 }
104 self
105 }
106
107 pub fn create_set(mut self, column: impl Into<String>, value: impl Into<FilterValue>) -> Self {
109 self.create_columns.push(column.into());
110 self.create_values.push(value.into());
111 self
112 }
113
114 pub fn update(
116 mut self,
117 values: impl IntoIterator<Item = (impl Into<String>, impl Into<FilterValue>)>,
118 ) -> Self {
119 for (col, val) in values {
120 self.update_columns.push(col.into());
121 self.update_values.push(val.into());
122 }
123 self
124 }
125
126 pub fn update_set(mut self, column: impl Into<String>, value: impl Into<FilterValue>) -> Self {
128 self.update_columns.push(column.into());
129 self.update_values.push(value.into());
130 self
131 }
132
133 pub fn select(mut self, select: impl Into<Select>) -> Self {
135 self.select = select.into();
136 self
137 }
138
139 pub fn with_where_input<W: crate::inputs::WhereUniqueInput<Model = M>>(mut self, w: W) -> Self {
141 self.filter = w.into_ir();
142 self
143 }
144
145 pub fn with_select_input<S: crate::inputs::SelectInput<Model = M>>(mut self, s: S) -> Self {
147 self.select = s.into_ir();
148 self
149 }
150
151 pub fn with_create_input<I>(mut self, input: I) -> Self
159 where
160 I: crate::inputs::CreateInput<Model = M, Data = crate::inputs::CreatePayload>,
161 {
162 let data: crate::inputs::CreatePayload = input.into_ir();
163 for (col, val) in data {
164 self.create_columns.push(col);
165 self.create_values.push(val);
166 }
167 self
168 }
169
170 pub fn with_update_input<I>(mut self, input: I) -> Self
178 where
179 I: crate::inputs::UpdateInput<Model = M, Data = crate::inputs::UpdatePayload>,
180 {
181 let data: crate::inputs::UpdatePayload = input.into_ir();
182 for (col, op) in data {
183 self.update_ops.push((col, op));
184 }
185 self
186 }
187
188 pub fn build_sql(
199 &self,
200 dialect: &dyn crate::dialect::SqlDialect,
201 ) -> (String, Vec<FilterValue>) {
202 let mut sql = String::new();
203 let mut params = Vec::new();
204 let mut param_idx = 1;
205
206 let derived_cols;
215 let conflict_cols: Vec<&str> = if !self.conflict_columns.is_empty() {
216 self.conflict_columns.iter().map(|s| s.as_str()).collect()
217 } else {
218 derived_cols = conflict_cols_from_filter(&self.filter).unwrap_or_default();
219 derived_cols.iter().map(|s| s.as_str()).collect()
220 };
221
222 let targetless_do_nothing = dialect.upsert_do_nothing_clause(&[]);
230 let do_nothing_no_target = self.update_ops.is_empty()
231 && self.update_columns.is_empty()
232 && conflict_cols.is_empty();
233 let insert_ignore =
234 do_nothing_no_target && targetless_do_nothing.starts_with(" ON DUPLICATE KEY");
235
236 if insert_ignore {
238 sql.push_str("INSERT IGNORE INTO ");
239 } else {
240 sql.push_str("INSERT INTO ");
241 }
242 sql.push_str(M::TABLE_NAME);
243
244 sql.push_str(" (");
246 sql.push_str(&self.create_columns.join(", "));
247 sql.push(')');
248
249 sql.push_str(" VALUES (");
251 let placeholders: Vec<_> = self
252 .create_values
253 .iter()
254 .map(|v| {
255 params.push(v.clone());
256 let p = dialect.placeholder(param_idx);
257 param_idx += 1;
258 p
259 })
260 .collect();
261 sql.push_str(&placeholders.join(", "));
262 sql.push(')');
263
264 let update_set = if !self.update_ops.is_empty() {
270 let update_parts: Vec<String> = self
271 .update_ops
272 .iter()
273 .map(|(col, op)| {
274 let placeholder = dialect.placeholder(param_idx);
275 let (fragment, value) = op.to_set_fragment(col, &placeholder);
276 if let Some(v) = value {
277 params.push(v);
278 param_idx += 1;
279 }
280 fragment
281 })
282 .collect();
283 update_parts.join(", ")
284 } else if !self.update_columns.is_empty() {
285 let update_parts: Vec<_> = self
286 .update_columns
287 .iter()
288 .zip(self.update_values.iter())
289 .map(|(col, val)| {
290 params.push(val.clone());
291 let part = format!("{} = {}", col, dialect.placeholder(param_idx));
292 param_idx += 1;
293 part
294 })
295 .collect();
296 update_parts.join(", ")
297 } else {
298 String::new()
299 };
300
301 if update_set.is_empty() {
302 if conflict_cols.is_empty() {
314 if insert_ignore {
315 } else if targetless_do_nothing.is_empty() {
318 tracing::warn!(
321 table = M::TABLE_NAME,
322 "upsert do-nothing has no single-statement equivalent on this \
323 dialect without a conflict target; emitting a plain INSERT"
324 );
325 } else {
326 sql.push_str(" ON CONFLICT DO NOTHING");
327 }
328 } else {
329 sql.push_str(&dialect.upsert_do_nothing_clause(&conflict_cols));
330 }
331 } else {
332 sql.push_str(&dialect.upsert_clause(&conflict_cols, &update_set));
334 }
335
336 sql.push_str(&dialect.returning_clause(&self.select.to_sql()));
338
339 (sql, params)
340 }
341
342 fn validate_fast_path(&self) -> QueryResult<()> {
354 if self.conflict_columns.is_empty() {
355 if !self.filter.is_none() && conflict_cols_from_filter(&self.filter).is_none() {
356 return Err(crate::error::QueryError::invalid_input(
357 "where",
358 "upsert `where` filter must be an equality on the conflict column(s) \
359 to serve as the ON CONFLICT target on the single-statement path",
360 )
361 .with_help(
362 "use `.on_conflict([...])` to name the conflict column(s) explicitly, \
363 or simplify the filter to `col = value` (an AND of equalities for \
364 composite keys)",
365 ));
366 }
367 let has_update = !self.update_ops.is_empty() || !self.update_columns.is_empty();
368 if has_update && self.filter.is_none() {
369 return Err(crate::error::QueryError::invalid_input(
370 "on_conflict",
371 "upsert with an update branch requires `.on_conflict(...)` or a \
372 `where` filter to name the conflict target",
373 )
374 .with_help(
375 "add `.on_conflict([\"<unique-column>\"])` (or a `where` equality on \
376 the unique column) — Postgres rejects `ON CONFLICT () DO UPDATE`",
377 ));
378 }
379 }
380 Ok(())
381 }
382
383 pub fn with_create_nested(mut self, nw: NestedWriteOp) -> Self
386 where
387 E: crate::capabilities::SupportsNestedWrites,
388 {
389 self.create_nested.push(nw);
390 self
391 }
392
393 pub fn with_update_nested(mut self, nw: NestedWriteOp) -> Self
396 where
397 E: crate::capabilities::SupportsNestedWrites,
398 {
399 self.update_nested.push(nw);
400 self
401 }
402
403 pub async fn exec(self) -> QueryResult<M>
423 where
424 M: Send + 'static + ModelWithPk,
425 {
426 if self.create_nested.is_empty() && self.update_nested.is_empty() {
428 self.validate_fast_path()?;
429 let dialect = self.engine.dialect();
430 let (sql, params) = self.build_sql(dialect);
431 return self.engine.execute_insert::<M>(&sql, params).await;
432 }
433
434 let parent_pk =
438 crate::operations::update::extract_pk_from_filter(&self.filter, M::PRIMARY_KEY[0])
439 .ok_or_else(|| {
440 crate::error::QueryError::invalid_input(
441 "where",
442 "nested writes inside `upsert!` require the `where:` clause to equal-match \
443 the primary-key column",
444 )
445 .with_help(format!(
446 "expected `where: {{ {pk}: <value> }}` on `{table}` — non-PK unique \
447 columns are not yet supported for nested writes inside upsert!. \
448 Lift this restriction by running the nested ops in a separate operation \
449 after looking up the row's PK.",
450 pk = M::PRIMARY_KEY[0],
451 table = M::TABLE_NAME,
452 ))
453 })?;
454
455 let UpsertOperation {
456 engine,
457 filter,
458 create_columns,
459 create_values,
460 update_columns,
461 update_values,
462 update_ops,
463 conflict_columns: _,
464 select,
465 create_nested,
466 update_nested,
467 _model,
468 } = self;
469
470 engine
471 .transaction(move |tx| async move {
472 let dialect = tx.dialect();
473
474 let (update_sql, update_params) = build_update_sql::<M>(
476 &filter,
477 &update_columns,
478 &update_values,
479 &update_ops,
480 dialect,
481 );
482 let affected = tx.execute_raw(&update_sql, update_params).await?;
483
484 let (row, fired_nested): (M, Vec<NestedWriteOp>) = if affected > 0 {
485 let (sel_sql, sel_params) =
489 build_select_by_pk_sql::<M>(parent_pk.clone(), &select, dialect);
490 let fetched: M = tx.query_one::<M>(&sel_sql, sel_params).await?;
491 (fetched, update_nested)
492 } else {
493 let (ins_sql, ins_params) =
495 build_insert_sql::<M>(&create_columns, &create_values, &select, dialect);
496 let inserted: M = tx.execute_insert::<M>(&ins_sql, ins_params).await?;
497 (inserted, create_nested)
498 };
499
500 let parent_pk_for_nested = if affected > 0 {
503 parent_pk
504 } else {
505 row.pk_value()
506 };
507 run_nested_ops(&tx, dialect, fired_nested, &parent_pk_for_nested).await?;
508
509 Ok(row)
510 })
511 .await
512 }
513}
514
515fn conflict_cols_from_filter(filter: &Filter) -> Option<Vec<String>> {
523 match filter {
524 Filter::Equals(name, _) => Some(vec![name.to_string()]),
525 Filter::And(parts) => {
526 let mut cols = Vec::with_capacity(parts.len());
527 for part in parts.iter() {
528 match part {
529 Filter::Equals(name, _) => cols.push(name.to_string()),
530 _ => return None,
531 }
532 }
533 if cols.is_empty() { None } else { Some(cols) }
534 }
535 _ => None,
536 }
537}
538
539fn build_update_sql<M: Model>(
545 filter: &Filter,
546 update_columns: &[String],
547 update_values: &[FilterValue],
548 update_ops: &[(String, WriteOp)],
549 dialect: &dyn crate::dialect::SqlDialect,
550) -> (String, Vec<FilterValue>) {
551 let mut sql = String::new();
552 let mut params = Vec::new();
553 let mut param_idx = 1;
554
555 sql.push_str("UPDATE ");
556 sql.push_str(M::TABLE_NAME);
557 sql.push_str(" SET ");
558
559 let set_parts: Vec<String> = if !update_ops.is_empty() {
560 update_ops
561 .iter()
562 .map(|(col, op)| {
563 let placeholder = dialect.placeholder(param_idx);
564 let (fragment, value) = op.to_set_fragment(col, &placeholder);
565 if let Some(v) = value {
566 params.push(v);
567 param_idx += 1;
568 }
569 fragment
570 })
571 .collect()
572 } else {
573 update_columns
574 .iter()
575 .zip(update_values.iter())
576 .map(|(col, val)| {
577 params.push(val.clone());
578 let part = format!("{} = {}", col, dialect.placeholder(param_idx));
579 param_idx += 1;
580 part
581 })
582 .collect()
583 };
584 sql.push_str(&set_parts.join(", "));
585
586 if !filter.is_none() {
587 let (where_sql, where_params) = filter.to_sql(param_idx - 1, dialect);
588 sql.push_str(" WHERE ");
589 sql.push_str(&where_sql);
590 params.extend(where_params);
591 }
592
593 (sql, params)
594}
595
596fn build_insert_sql<M: Model>(
598 columns: &[String],
599 values: &[FilterValue],
600 select: &Select,
601 dialect: &dyn crate::dialect::SqlDialect,
602) -> (String, Vec<FilterValue>) {
603 let mut sql = String::new();
604 sql.push_str("INSERT INTO ");
605 sql.push_str(M::TABLE_NAME);
606 sql.push_str(" (");
607 sql.push_str(&columns.join(", "));
608 sql.push(')');
609 sql.push_str(" VALUES (");
610 let placeholders: Vec<_> = (1..=values.len()).map(|i| dialect.placeholder(i)).collect();
611 sql.push_str(&placeholders.join(", "));
612 sql.push(')');
613 sql.push_str(&dialect.returning_clause(&select.to_sql()));
614 (sql, values.to_vec())
615}
616
617fn build_select_by_pk_sql<M: Model>(
620 pk: FilterValue,
621 select: &Select,
622 dialect: &dyn crate::dialect::SqlDialect,
623) -> (String, Vec<FilterValue>) {
624 let cols = select.to_sql();
625 let sql = format!(
626 "SELECT {} FROM {} WHERE {} = {}",
627 if cols.is_empty() || cols == "*" {
628 "*".to_string()
629 } else {
630 cols
631 },
632 M::TABLE_NAME,
633 dialect.quote_ident(M::PRIMARY_KEY[0]),
634 dialect.placeholder(1),
635 );
636 (sql, vec![pk])
637}
638
639async fn run_nested_ops<E: QueryEngine>(
642 tx: &E,
643 dialect: &dyn crate::dialect::SqlDialect,
644 nested: Vec<NestedWriteOp>,
645 parent_pk: &FilterValue,
646) -> QueryResult<()> {
647 let mut idx = 0;
648 while idx < nested.len() {
649 if let NestedWriteOp::Connect {
650 target_table: run_table,
651 foreign_key: run_fk,
652 target_pk: run_target_pk,
653 ..
654 } = &nested[idx]
655 {
656 let run_table = *run_table;
657 let run_fk = *run_fk;
658 let run_target_pk = *run_target_pk;
659 let mut end = idx + 1;
660 while end < nested.len() {
661 match &nested[end] {
662 NestedWriteOp::Connect {
663 target_table,
664 foreign_key,
665 target_pk,
666 ..
667 } if *target_table == run_table
668 && *foreign_key == run_fk
669 && *target_pk == run_target_pk =>
670 {
671 end += 1;
672 }
673 _ => break,
674 }
675 }
676
677 if end - idx == 1 {
678 let op = nested[idx].clone();
679 op.execute(tx, parent_pk).await?;
680 } else {
681 let expected = (end - idx) as u64;
682 let mut pks: Vec<FilterValue> = Vec::with_capacity(end - idx + 1);
683 pks.push(parent_pk.clone());
684 for op in &nested[idx..end] {
685 if let NestedWriteOp::Connect { pk, .. } = op {
686 pks.push(pk.clone());
687 }
688 }
689 let placeholders: Vec<String> =
690 (2..=pks.len()).map(|i| dialect.placeholder(i)).collect();
691 let sql = format!(
692 "UPDATE {} SET {} = {} WHERE {} IN ({})",
693 dialect.quote_ident(run_table),
694 dialect.quote_ident(run_fk),
695 dialect.placeholder(1),
696 dialect.quote_ident(run_target_pk),
697 placeholders.join(", "),
698 );
699 let affected = tx.execute_raw(&sql, pks).await?;
700 if affected != expected {
701 return Err(crate::error::QueryError::not_found(run_table)
702 .with_context("Nested Connect batch")
703 .with_help(format!(
704 "Expected {} matching rows but UPDATE affected {}",
705 expected, affected
706 )));
707 }
708 }
709 idx = end;
710 } else {
711 let op = nested[idx].clone();
712 op.execute(tx, parent_pk).await?;
713 idx += 1;
714 }
715 }
716 Ok(())
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722 use crate::error::QueryError;
723
724 #[derive(Debug)]
725 struct TestModel;
726
727 impl Model for TestModel {
728 const MODEL_NAME: &'static str = "TestModel";
729 const TABLE_NAME: &'static str = "test_models";
730 const PRIMARY_KEY: &'static [&'static str] = &["id"];
731 const COLUMNS: &'static [&'static str] = &["id", "name", "email"];
732 }
733
734 impl crate::row::FromRow for TestModel {
735 fn from_row(_row: &impl crate::row::RowRef) -> Result<Self, crate::row::RowError> {
736 Ok(TestModel)
737 }
738 }
739
740 impl crate::traits::ModelWithPk for TestModel {
744 fn pk_value(&self) -> FilterValue {
745 FilterValue::Int(0)
746 }
747 fn get_column_value(&self, _column: &str) -> Option<FilterValue> {
748 None
749 }
750 }
751
752 #[derive(Clone)]
753 struct MockEngine;
754
755 impl QueryEngine for MockEngine {
756 fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
757 &crate::dialect::Postgres
758 }
759
760 fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
761 &self,
762 _sql: &str,
763 _params: Vec<FilterValue>,
764 ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
765 Box::pin(async { Ok(Vec::new()) })
766 }
767
768 fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
769 &self,
770 _sql: &str,
771 _params: Vec<FilterValue>,
772 ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
773 Box::pin(async { Err(QueryError::not_found("test")) })
774 }
775
776 fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
777 &self,
778 _sql: &str,
779 _params: Vec<FilterValue>,
780 ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
781 Box::pin(async { Ok(None) })
782 }
783
784 fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
785 &self,
786 _sql: &str,
787 _params: Vec<FilterValue>,
788 ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
789 Box::pin(async { Err(QueryError::not_found("test")) })
790 }
791
792 fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
793 &self,
794 _sql: &str,
795 _params: Vec<FilterValue>,
796 ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
797 Box::pin(async { Ok(Vec::new()) })
798 }
799
800 fn execute_delete(
801 &self,
802 _sql: &str,
803 _params: Vec<FilterValue>,
804 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
805 Box::pin(async { Ok(0) })
806 }
807
808 fn execute_raw(
809 &self,
810 _sql: &str,
811 _params: Vec<FilterValue>,
812 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
813 Box::pin(async { Ok(0) })
814 }
815
816 fn count(
817 &self,
818 _sql: &str,
819 _params: Vec<FilterValue>,
820 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
821 Box::pin(async { Ok(0) })
822 }
823 }
824
825 #[test]
828 fn test_upsert_new() {
829 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine);
830 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
831
832 assert!(sql.contains("INSERT INTO test_models"));
833 assert!(sql.contains("ON CONFLICT"));
834 assert!(sql.contains("RETURNING *"));
835 assert!(params.is_empty());
836 }
837
838 #[test]
839 fn test_upsert_basic() {
840 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
841 .on_conflict(["email"])
842 .create_set("email", "test@example.com")
843 .create_set("name", "Test")
844 .update_set("name", "Updated");
845
846 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
847
848 assert!(sql.contains("INSERT INTO test_models"));
849 assert!(sql.contains("ON CONFLICT (\"email\")"));
850 assert!(sql.contains("DO UPDATE SET"));
851 assert!(sql.contains("RETURNING *"));
852 assert_eq!(params.len(), 3); }
854
855 #[test]
858 fn test_upsert_single_conflict_column() {
859 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
860 .on_conflict(["id"])
861 .create_set("id", FilterValue::Int(1));
862
863 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
864
865 assert!(sql.contains("ON CONFLICT (\"id\")"));
866 }
867
868 #[test]
869 fn test_upsert_multiple_conflict_columns() {
870 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
871 .on_conflict(["tenant_id", "email"])
872 .create_set("email", "test@example.com")
873 .create_set("tenant_id", FilterValue::Int(1));
874
875 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
876
877 assert!(sql.contains("ON CONFLICT (\"tenant_id\", \"email\")"));
878 }
879
880 #[test]
881 fn test_upsert_without_conflict_columns() {
882 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
883 .create_set("email", "test@example.com");
884
885 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
886
887 assert!(sql.contains("ON CONFLICT"));
888 assert!(!sql.contains("ON CONFLICT ("));
889 }
890
891 #[test]
894 fn test_upsert_create_with_set() {
895 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
896 .on_conflict(["email"])
897 .create_set("email", "test@example.com")
898 .create_set("name", "Test User");
899
900 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
901
902 assert!(sql.contains("(email, name)"));
903 assert!(sql.contains("VALUES ($1, $2)"));
904 assert_eq!(params.len(), 2);
905 }
906
907 #[test]
908 fn test_upsert_create_with_iterator() {
909 let create_data = vec![
910 ("email", FilterValue::String("test@example.com".to_string())),
911 ("name", FilterValue::String("Test User".to_string())),
912 ("age", FilterValue::Int(25)),
913 ];
914 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
915 .on_conflict(["email"])
916 .create(create_data);
917
918 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
919
920 assert!(sql.contains("(email, name, age)"));
921 assert!(sql.contains("VALUES ($1, $2, $3)"));
922 assert_eq!(params.len(), 3);
923 }
924
925 #[test]
928 fn test_upsert_update_with_set() {
929 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
930 .on_conflict(["email"])
931 .create_set("email", "test@example.com")
932 .update_set("name", "Updated Name")
933 .update_set("updated_at", "2024-01-01");
934
935 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
936
937 assert!(sql.contains("DO UPDATE SET"));
938 assert!(sql.contains("name = $"));
939 assert!(sql.contains("updated_at = $"));
940 assert_eq!(params.len(), 3); }
942
943 #[test]
944 fn test_upsert_update_with_iterator() {
945 let update_data = vec![
946 ("name", FilterValue::String("Updated".to_string())),
947 ("status", FilterValue::String("active".to_string())),
948 ];
949 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
950 .on_conflict(["id"])
951 .create_set("id", FilterValue::Int(1))
952 .update(update_data);
953
954 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
955
956 assert!(sql.contains("DO UPDATE SET"));
957 assert_eq!(params.len(), 3); }
959
960 #[test]
963 fn test_upsert_do_nothing() {
964 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
965 .on_conflict(["email"])
966 .create_set("email", "test@example.com");
967
968 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
969
970 assert!(sql.contains("DO NOTHING"));
971 assert!(!sql.contains("DO UPDATE"));
972 }
973
974 #[test]
975 fn test_upsert_do_nothing_multiple_create() {
976 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
977 .on_conflict(["email"])
978 .create_set("email", "test@example.com")
979 .create_set("name", "Test");
980
981 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
982
983 assert!(sql.contains("DO NOTHING"));
984 assert_eq!(params.len(), 2);
985 }
986
987 #[test]
988 fn test_upsert_do_nothing_mysql() {
989 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
990 .on_conflict(["email"])
991 .create_set("email", "test@example.com");
992
993 let (sql, _) = op.build_sql(&crate::dialect::Mysql);
994
995 assert!(
998 sql.contains("ON DUPLICATE KEY UPDATE `email` = `email`"),
999 "got: {sql}"
1000 );
1001 assert!(!sql.contains("ON CONFLICT"), "got: {sql}");
1002 }
1003
1004 #[test]
1005 fn test_upsert_do_nothing_mysql_no_conflict_target() {
1006 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1011 .create_set("email", "test@example.com");
1012
1013 let (sql, _) = op.build_sql(&crate::dialect::Mysql);
1014
1015 assert!(
1016 sql.starts_with("INSERT IGNORE INTO test_models"),
1017 "expected INSERT IGNORE prefix, got: {sql}"
1018 );
1019 assert!(!sql.contains("ON CONFLICT"), "got: {sql}");
1020 assert!(
1021 !sql.contains("ON DUPLICATE KEY"),
1022 "INSERT IGNORE replaces the self-assign suffix: {sql}"
1023 );
1024 }
1025
1026 #[test]
1029 fn test_upsert_with_select() {
1030 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1031 .on_conflict(["email"])
1032 .create_set("email", "test@example.com")
1033 .update_set("name", "Updated")
1034 .select(Select::fields(["id", "email"]));
1035
1036 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1037
1038 assert!(sql.contains("RETURNING id, email"));
1039 assert!(!sql.contains("RETURNING *"));
1040 }
1041
1042 #[test]
1043 fn test_upsert_select_all() {
1044 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1045 .on_conflict(["email"])
1046 .create_set("email", "test@example.com")
1047 .select(Select::All);
1048
1049 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1050
1051 assert!(sql.contains("RETURNING *"));
1052 }
1053
1054 #[test]
1057 fn test_upsert_with_where() {
1058 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1059 .r#where(Filter::Equals(
1060 "email".into(),
1061 FilterValue::String("test@example.com".to_string()),
1062 ))
1063 .on_conflict(["email"])
1064 .create_set("email", "test@example.com");
1065
1066 let (_, _) = op.build_sql(&crate::dialect::Postgres);
1067 }
1069
1070 #[test]
1073 fn test_upsert_where_derives_conflict_target() {
1074 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1077 .r#where(Filter::Equals(
1078 "email".into(),
1079 FilterValue::String("test@example.com".to_string()),
1080 ))
1081 .create_set("email", "test@example.com")
1082 .update_set("name", "Updated");
1083
1084 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1085
1086 assert!(sql.contains("ON CONFLICT (\"email\")"), "got: {sql}");
1087 assert!(sql.contains("DO UPDATE SET"), "got: {sql}");
1088 }
1089
1090 #[tokio::test]
1091 async fn test_upsert_update_without_conflict_target_errors() {
1092 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1096 .create_set("email", "test@example.com")
1097 .update_set("name", "Updated");
1098
1099 let err = op.exec().await.unwrap_err();
1100
1101 assert_eq!(err.code, crate::error::ErrorCode::InvalidParameter);
1102 let msg = format!("{err}");
1103 assert!(msg.contains("on_conflict"), "msg: {msg}");
1104 }
1105
1106 #[tokio::test]
1107 async fn test_upsert_non_derivable_where_without_conflict_errors() {
1108 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1111 .r#where(Filter::Gt("id".into(), FilterValue::Int(3)))
1112 .create_set("email", "test@example.com");
1113
1114 let err = op.exec().await.unwrap_err();
1115
1116 assert_eq!(err.code, crate::error::ErrorCode::InvalidParameter);
1117 let msg = format!("{err}");
1118 assert!(msg.contains("where"), "msg: {msg}");
1119 }
1120
1121 #[test]
1124 fn test_upsert_sql_structure() {
1125 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1126 .on_conflict(["email"])
1127 .create_set("email", "test@example.com")
1128 .update_set("name", "Updated")
1129 .select(Select::fields(["id"]));
1130
1131 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1132
1133 let insert_pos = sql.find("INSERT INTO").unwrap();
1134 let values_pos = sql.find("VALUES").unwrap();
1135 let conflict_pos = sql.find("ON CONFLICT").unwrap();
1136 let update_pos = sql.find("DO UPDATE SET").unwrap();
1137 let returning_pos = sql.find("RETURNING").unwrap();
1138
1139 assert!(insert_pos < values_pos);
1140 assert!(values_pos < conflict_pos);
1141 assert!(conflict_pos < update_pos);
1142 assert!(update_pos < returning_pos);
1143 }
1144
1145 #[test]
1146 fn test_upsert_table_name() {
1147 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine);
1148 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
1149
1150 assert!(sql.contains("test_models"));
1151 }
1152
1153 #[test]
1156 fn test_upsert_param_ordering() {
1157 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1158 .on_conflict(["email"])
1159 .create_set("email", "create@test.com")
1160 .create_set("name", "Create Name")
1161 .update_set("name", "Update Name");
1162
1163 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1164
1165 assert!(sql.contains("VALUES ($1, $2)"));
1167 assert!(sql.contains("name = $3"));
1168 assert_eq!(params.len(), 3);
1169 assert_eq!(
1170 params[0],
1171 FilterValue::String("create@test.com".to_string())
1172 );
1173 assert_eq!(params[1], FilterValue::String("Create Name".to_string()));
1174 assert_eq!(params[2], FilterValue::String("Update Name".to_string()));
1175 }
1176
1177 #[tokio::test]
1180 async fn test_upsert_exec() {
1181 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1182 .on_conflict(["email"])
1183 .create_set("email", "test@example.com");
1184
1185 let result = op.exec().await;
1186
1187 assert!(result.is_err());
1189 }
1190
1191 #[test]
1194 fn test_upsert_full_chain() {
1195 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1196 .r#where(Filter::Equals(
1197 "email".into(),
1198 FilterValue::String("test@example.com".to_string()),
1199 ))
1200 .on_conflict(["email"])
1201 .create_set("email", "test@example.com")
1202 .create_set("name", "Test User")
1203 .update_set("name", "Updated User")
1204 .select(Select::fields(["id", "name", "email"]));
1205
1206 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1207
1208 assert!(sql.contains("INSERT INTO test_models"));
1209 assert!(sql.contains("ON CONFLICT (\"email\")"));
1210 assert!(sql.contains("DO UPDATE SET"));
1211 assert!(sql.contains("RETURNING id, name, email"));
1212 assert_eq!(params.len(), 3);
1213 }
1214
1215 #[test]
1218 fn test_upsert_with_null_value() {
1219 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1220 .on_conflict(["id"])
1221 .create_set("id", FilterValue::Int(1))
1222 .create_set("nickname", FilterValue::Null);
1223
1224 let (_, params) = op.build_sql(&crate::dialect::Postgres);
1225
1226 assert_eq!(params[1], FilterValue::Null);
1227 }
1228
1229 #[test]
1230 fn test_upsert_with_boolean_value() {
1231 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1232 .on_conflict(["id"])
1233 .create_set("id", FilterValue::Int(1))
1234 .create_set("active", FilterValue::Bool(true))
1235 .update_set("active", FilterValue::Bool(false));
1236
1237 let (_, params) = op.build_sql(&crate::dialect::Postgres);
1238
1239 assert_eq!(params[1], FilterValue::Bool(true));
1240 assert_eq!(params[2], FilterValue::Bool(false));
1241 }
1242
1243 #[test]
1244 fn test_upsert_with_numeric_values() {
1245 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1246 .on_conflict(["id"])
1247 .create_set("id", FilterValue::Int(1))
1248 .create_set("score", FilterValue::Float(99.5));
1249
1250 let (_, params) = op.build_sql(&crate::dialect::Postgres);
1251
1252 assert_eq!(params[0], FilterValue::Int(1));
1253 assert_eq!(params[1], FilterValue::Float(99.5));
1254 }
1255
1256 #[test]
1257 fn test_upsert_with_json_value() {
1258 let json = serde_json::json!({"key": "value"});
1259 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1260 .on_conflict(["id"])
1261 .create_set("id", FilterValue::Int(1))
1262 .create_set("metadata", FilterValue::Json(json.clone()));
1263
1264 let (_, params) = op.build_sql(&crate::dialect::Postgres);
1265
1266 assert_eq!(params[1], FilterValue::Json(json));
1267 }
1268
1269 struct MockCreateInput(Vec<(String, FilterValue)>);
1272
1273 impl crate::inputs::CreateInput for MockCreateInput {
1274 type Model = TestModel;
1275 type Data = crate::inputs::CreatePayload;
1276 fn into_ir(self) -> Self::Data {
1277 self.0
1278 }
1279 }
1280
1281 struct MockUpdateInput(Vec<(String, WriteOp)>);
1282
1283 impl crate::inputs::UpdateInput for MockUpdateInput {
1284 type Model = TestModel;
1285 type Data = crate::inputs::UpdatePayload;
1286 fn into_ir(self) -> Self::Data {
1287 self.0
1288 }
1289 }
1290
1291 #[test]
1292 fn upsert_with_create_input_appends_create_columns() {
1293 let input = MockCreateInput(vec![
1294 ("email".into(), FilterValue::String("a@x.com".into())),
1295 ("name".into(), FilterValue::String("Alice".into())),
1296 ]);
1297 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1298 .on_conflict(["email"])
1299 .with_create_input(input)
1300 .update_set("name", "Updated");
1301
1302 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1303 assert!(sql.contains("(email, name)"), "got: {sql}");
1304 assert!(sql.contains("VALUES ($1, $2)"), "got: {sql}");
1305 assert!(sql.contains("ON CONFLICT (\"email\")"));
1306 assert_eq!(params.len(), 3);
1308 }
1309
1310 #[test]
1311 fn upsert_with_update_input_uses_atomic_ops() {
1312 let create = MockCreateInput(vec![("id".into(), FilterValue::Int(1))]);
1313 let update = MockUpdateInput(vec![
1314 (
1315 "name".into(),
1316 WriteOp::Set(FilterValue::String("Renamed".into())),
1317 ),
1318 (
1319 "login_count".into(),
1320 WriteOp::Increment(FilterValue::Int(1)),
1321 ),
1322 ]);
1323 let op = UpsertOperation::<MockEngine, TestModel>::new(MockEngine)
1324 .on_conflict(["id"])
1325 .with_create_input(create)
1326 .with_update_input(update);
1327
1328 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
1329 assert!(sql.contains("login_count = login_count + $"), "got: {sql}");
1331 assert_eq!(params.len(), 3);
1333 }
1334
1335 use std::sync::{Arc, Mutex};
1338
1339 type StatementLog = Arc<Mutex<Vec<(String, Vec<FilterValue>)>>>;
1340
1341 #[derive(Clone)]
1345 struct RecordingEngine {
1346 recorded: StatementLog,
1347 affected: Arc<Mutex<Vec<u64>>>,
1348 }
1349
1350 impl RecordingEngine {
1351 fn with_affected(seq: Vec<u64>) -> Self {
1352 let mut rev = seq;
1353 rev.reverse();
1354 Self {
1355 recorded: Arc::new(Mutex::new(Vec::new())),
1356 affected: Arc::new(Mutex::new(rev)),
1357 }
1358 }
1359
1360 fn statements(&self) -> Vec<(String, Vec<FilterValue>)> {
1361 self.recorded.lock().unwrap().clone()
1362 }
1363 }
1364
1365 impl crate::capabilities::SupportsNestedWrites for RecordingEngine {}
1366
1367 impl QueryEngine for RecordingEngine {
1368 fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
1369 &crate::dialect::Postgres
1370 }
1371
1372 fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
1373 &self,
1374 _sql: &str,
1375 _params: Vec<FilterValue>,
1376 ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
1377 Box::pin(async { Ok(Vec::new()) })
1378 }
1379
1380 fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
1381 &self,
1382 sql: &str,
1383 params: Vec<FilterValue>,
1384 ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
1385 let recorded = self.recorded.clone();
1386 let sql = sql.to_string();
1387 Box::pin(async move {
1388 recorded.lock().unwrap().push((sql, params));
1389 T::from_row(&CannedRow).map_err(|e| QueryError::internal(e.to_string()))
1390 })
1391 }
1392
1393 fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
1394 &self,
1395 _sql: &str,
1396 _params: Vec<FilterValue>,
1397 ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
1398 Box::pin(async { Ok(None) })
1399 }
1400
1401 fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
1402 &self,
1403 sql: &str,
1404 params: Vec<FilterValue>,
1405 ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
1406 let recorded = self.recorded.clone();
1407 let sql = sql.to_string();
1408 Box::pin(async move {
1409 recorded.lock().unwrap().push((sql, params));
1410 T::from_row(&CannedRow).map_err(|e| QueryError::internal(e.to_string()))
1411 })
1412 }
1413
1414 fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
1415 &self,
1416 _sql: &str,
1417 _params: Vec<FilterValue>,
1418 ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
1419 Box::pin(async { Ok(Vec::new()) })
1420 }
1421
1422 fn execute_delete(
1423 &self,
1424 _sql: &str,
1425 _params: Vec<FilterValue>,
1426 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1427 Box::pin(async { Ok(0) })
1428 }
1429
1430 fn execute_raw(
1431 &self,
1432 sql: &str,
1433 params: Vec<FilterValue>,
1434 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1435 let recorded = self.recorded.clone();
1436 let affected = self.affected.clone();
1437 let sql_string = sql.to_string();
1438 let default = if sql.contains(" IN (") {
1439 (params.len() as u64).saturating_sub(1)
1440 } else {
1441 1
1442 };
1443 Box::pin(async move {
1444 recorded.lock().unwrap().push((sql_string, params));
1445 Ok(affected.lock().unwrap().pop().unwrap_or(default))
1446 })
1447 }
1448
1449 fn count(
1450 &self,
1451 _sql: &str,
1452 _params: Vec<FilterValue>,
1453 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
1454 Box::pin(async { Ok(0) })
1455 }
1456 }
1457
1458 struct CannedRow;
1461
1462 impl crate::row::RowRef for CannedRow {
1463 fn get_i32(&self, _column: &str) -> Result<i32, crate::row::RowError> {
1464 Ok(0)
1465 }
1466 fn get_i32_opt(&self, _column: &str) -> Result<Option<i32>, crate::row::RowError> {
1467 Ok(Some(0))
1468 }
1469 fn get_i64(&self, _column: &str) -> Result<i64, crate::row::RowError> {
1470 Ok(0)
1471 }
1472 fn get_i64_opt(&self, _column: &str) -> Result<Option<i64>, crate::row::RowError> {
1473 Ok(None)
1474 }
1475 fn get_f64(&self, _column: &str) -> Result<f64, crate::row::RowError> {
1476 Ok(0.0)
1477 }
1478 fn get_f64_opt(&self, _column: &str) -> Result<Option<f64>, crate::row::RowError> {
1479 Ok(None)
1480 }
1481 fn get_bool(&self, _column: &str) -> Result<bool, crate::row::RowError> {
1482 Ok(false)
1483 }
1484 fn get_bool_opt(&self, _column: &str) -> Result<Option<bool>, crate::row::RowError> {
1485 Ok(None)
1486 }
1487 fn get_str(&self, _column: &str) -> Result<&str, crate::row::RowError> {
1488 Ok("canned")
1489 }
1490 fn get_str_opt(&self, _column: &str) -> Result<Option<&str>, crate::row::RowError> {
1491 Ok(Some("canned"))
1492 }
1493 fn get_bytes(&self, _column: &str) -> Result<&[u8], crate::row::RowError> {
1494 Ok(b"")
1495 }
1496 fn get_bytes_opt(&self, _column: &str) -> Result<Option<&[u8]>, crate::row::RowError> {
1497 Ok(None)
1498 }
1499 }
1500
1501 #[tokio::test]
1502 async fn upsert_with_nested_in_update_branch_fires_update_nested_only() {
1503 let engine = RecordingEngine::with_affected(vec![1]);
1505 let op = UpsertOperation::<RecordingEngine, TestModel>::new(engine.clone())
1506 .r#where(Filter::Equals("id".into(), FilterValue::Int(7)))
1507 .create_set("id", FilterValue::Int(7))
1508 .create_set("email", "new@x.com")
1509 .update_set("name", "Renamed")
1510 .with_update_nested(NestedWriteOp::Disconnect {
1511 relation: "posts",
1512 target_table: "posts",
1513 foreign_key: "author_id",
1514 target_pk: "id",
1515 pk: FilterValue::Int(42),
1516 })
1517 .with_create_nested(NestedWriteOp::Create {
1518 relation: "posts",
1519 target_table: "posts",
1520 foreign_key: "author_id",
1521 payload: vec![vec![("title".into(), FilterValue::String("p1".into()))]],
1522 });
1523
1524 let _ = op.exec().await.expect("upsert update branch");
1525
1526 let stmts = engine.statements();
1527 assert_eq!(
1529 stmts.len(),
1530 3,
1531 "UPDATE + SELECT + nested disconnect; got {stmts:#?}"
1532 );
1533 assert!(
1534 stmts[0].0.starts_with("UPDATE test_models"),
1535 "first stmt should be parent UPDATE: {}",
1536 stmts[0].0
1537 );
1538 assert!(
1539 stmts[1].0.starts_with("SELECT"),
1540 "second stmt should re-fetch the row: {}",
1541 stmts[1].0
1542 );
1543 assert!(stmts[2].0.contains("UPDATE"), "got: {}", stmts[2].0);
1545 assert!(stmts[2].0.contains("posts"), "got: {}", stmts[2].0);
1546 assert!(stmts[2].0.contains("NULL"), "got: {}", stmts[2].0);
1547 assert!(
1549 !stmts.iter().any(|(s, _)| s.starts_with("INSERT INTO")),
1550 "no INSERT must fire on update branch: {stmts:#?}"
1551 );
1552 }
1553
1554 #[tokio::test]
1555 async fn upsert_with_nested_in_create_branch_fires_create_nested_only() {
1556 let engine = RecordingEngine::with_affected(vec![0]);
1558 let op = UpsertOperation::<RecordingEngine, TestModel>::new(engine.clone())
1559 .r#where(Filter::Equals("id".into(), FilterValue::Int(7)))
1560 .create_set("id", FilterValue::Int(7))
1561 .create_set("email", "new@x.com")
1562 .update_set("name", "Renamed")
1563 .with_update_nested(NestedWriteOp::Disconnect {
1564 relation: "posts",
1565 target_table: "posts",
1566 foreign_key: "author_id",
1567 target_pk: "id",
1568 pk: FilterValue::Int(42),
1569 })
1570 .with_create_nested(NestedWriteOp::Create {
1571 relation: "posts",
1572 target_table: "posts",
1573 foreign_key: "author_id",
1574 payload: vec![vec![("title".into(), FilterValue::String("p1".into()))]],
1575 });
1576
1577 let _ = op.exec().await.expect("upsert create branch");
1578
1579 let stmts = engine.statements();
1580 assert_eq!(
1582 stmts.len(),
1583 3,
1584 "UPDATE + INSERT + nested child INSERT; got {stmts:#?}"
1585 );
1586 assert!(
1587 stmts[0].0.starts_with("UPDATE test_models"),
1588 "first stmt should be parent UPDATE: {}",
1589 stmts[0].0
1590 );
1591 assert!(
1592 stmts[1].0.contains("INSERT INTO test_models"),
1593 "second stmt should be the create-branch INSERT: {}",
1594 stmts[1].0
1595 );
1596 assert!(
1597 stmts[2].0.contains("INSERT INTO"),
1598 "third stmt should be the nested Create child INSERT: {}",
1599 stmts[2].0
1600 );
1601 assert!(
1602 stmts[2].0.contains("posts"),
1603 "nested INSERT targets posts: {}",
1604 stmts[2].0
1605 );
1606 assert!(
1609 !stmts.iter().any(|(s, _)| s.starts_with("SELECT")),
1610 "no SELECT must fire on create branch: {stmts:#?}"
1611 );
1612 assert!(
1613 !stmts
1614 .iter()
1615 .any(|(s, _)| s.contains("UPDATE \"posts\"") || s.contains("UPDATE posts")),
1616 "no nested Disconnect on update_nested must fire: {stmts:#?}"
1617 );
1618 }
1619
1620 #[tokio::test]
1621 async fn upsert_both_branches_carry_nested_only_one_fires() {
1622 let engine_u = RecordingEngine::with_affected(vec![1]);
1626 let _ = UpsertOperation::<RecordingEngine, TestModel>::new(engine_u.clone())
1627 .r#where(Filter::Equals("id".into(), FilterValue::Int(7)))
1628 .create_set("id", FilterValue::Int(7))
1629 .update_set("name", "Renamed")
1630 .with_update_nested(NestedWriteOp::Disconnect {
1631 relation: "posts",
1632 target_table: "posts",
1633 foreign_key: "author_id",
1634 target_pk: "id",
1635 pk: FilterValue::Int(42),
1636 })
1637 .with_create_nested(NestedWriteOp::Create {
1638 relation: "posts",
1639 target_table: "posts",
1640 foreign_key: "author_id",
1641 payload: vec![vec![("title".into(), FilterValue::String("p".into()))]],
1642 })
1643 .exec()
1644 .await
1645 .expect("update branch");
1646 let u_stmts = engine_u.statements();
1647 assert!(
1649 u_stmts.iter().any(|(s, _)| s.contains("NULL")),
1650 "expected nested Disconnect: {u_stmts:#?}"
1651 );
1652 assert!(
1653 !u_stmts
1654 .iter()
1655 .any(|(s, _)| s.contains("INSERT INTO") && s.contains("posts")),
1656 "no nested Create child INSERT on update branch: {u_stmts:#?}"
1657 );
1658
1659 let engine_c = RecordingEngine::with_affected(vec![0]);
1661 let _ = UpsertOperation::<RecordingEngine, TestModel>::new(engine_c.clone())
1662 .r#where(Filter::Equals("id".into(), FilterValue::Int(7)))
1663 .create_set("id", FilterValue::Int(7))
1664 .update_set("name", "Renamed")
1665 .with_update_nested(NestedWriteOp::Disconnect {
1666 relation: "posts",
1667 target_table: "posts",
1668 foreign_key: "author_id",
1669 target_pk: "id",
1670 pk: FilterValue::Int(42),
1671 })
1672 .with_create_nested(NestedWriteOp::Create {
1673 relation: "posts",
1674 target_table: "posts",
1675 foreign_key: "author_id",
1676 payload: vec![vec![("title".into(), FilterValue::String("p".into()))]],
1677 })
1678 .exec()
1679 .await
1680 .expect("create branch");
1681 let c_stmts = engine_c.statements();
1682 assert!(
1684 c_stmts
1685 .iter()
1686 .any(|(s, _)| s.contains("INSERT INTO") && s.contains("posts")),
1687 "expected nested Create child INSERT: {c_stmts:#?}"
1688 );
1689 assert!(
1690 !c_stmts.iter().any(|(s, _)| s.contains("NULL")),
1691 "no nested Disconnect on create branch: {c_stmts:#?}"
1692 );
1693 }
1694}