Skip to content

feat: add upsert method#137

Open
robinstraub wants to merge 5 commits into
masterfrom
add-upsert
Open

feat: add upsert method#137
robinstraub wants to merge 5 commits into
masterfrom
add-upsert

Conversation

@robinstraub
Copy link
Copy Markdown
Owner

@robinstraub robinstraub commented May 9, 2026

resolves #136

Summary by CodeRabbit

  • New Features

    • Added .make() to build model instances in memory and .upsert() for bulk insert/update with configurable conflict keys.
  • Documentation

    • New docs and cookbook examples for building without persisting and bulk upsert usage, conflict-key guidance, and examples.
  • Tests

    • Added upsert tests covering inserts, updates, composite keys, tuple conflict targets, and empty-collection no-op.
  • Database Migrations

    • Added composite unique constraint on (name, price_cents) for products.

Review Change Stack

@robinstraub robinstraub self-assigned this May 9, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2edbd090-ef90-4b3f-98b8-e83fa5668731

📥 Commits

Reviewing files that changed from the base of the PR and between 587b56a and 490384a.

📒 Files selected for processing (4)
  • fabrique/tests/upsert.rs
  • migrations/mysql/00001_initial_schema.sql
  • migrations/postgres/00001_initial_schema.sql
  • migrations/sqlite/00001_initial_schema.sql

📝 Walkthrough

Walkthrough

Implements bulk upsert: adds UniqueBy and Upsert traits, extends Persist with push_bind_values for batched bind construction, generates bind helpers and factory.make(), exposes upsert via crate re-exports, adds tests, docs, and product migrations with composite UNIQUE constraints.

Changes

Bulk Upsert for fabrique Models

Layer / File(s) Summary
Trait Contracts & Persist Enhancement
fabrique-core/src/model.rs, fabrique-core/src/upsert.rs, fabrique-core/src/lib.rs
Persist<DB> gains push_bind_values. New UniqueBy<M> trait (column_names()) and Upsert<DB> trait are declared; core module and re-exports added.
Core Upsert Implementation
fabrique-core/src/upsert.rs
impl_unique_by! macro and Vec<M> Upsert impl build bulk INSERT with dialect-specific conflict/update SQL, compute update columns by excluding unique columns, and execute the query.
Persist Code Generation
fabrique-derive/src/codegen/persist.rs
Generator adds push_bind_values emission that converts fields and pushes per-row DB values into a sqlx::Separated builder for bulk queries.
Column UniqueBy Generation
fabrique-derive/src/codegen/columns.rs
Columns codegen now emits impl ::fabrique::UniqueBy<Model> for each column ZST returning the column name slice; tests updated.
Factory make() Method
fabrique-derive/src/codegen/factory.rs
Generator emits pub fn make(self) -> Model that constructs an in-memory model using faker expressions or seeded defaults for bulk-prep scenarios.
API Module Exports
fabrique/src/lib.rs, fabrique/src/upsert.rs, fabrique/src/prelude.rs
Public re-exports and shim added so fabrique::upsert and its items are available from the crate root and prelude.
Tests
fabrique/tests/upsert.rs
Tests cover inserting new rows, updating existing rows via upsert, composite conflict targets, tuple handling, and empty-vector no-op.
Documentation
docs/src/concepts/factories.md, docs/src/concepts/models.md, docs/src/cookbook/bulk-update-and-upsert.md
Doc updates add make() guidance, a convenience example with bulk upsert, and a cookbook section explaining .upsert() usage and conflict-target rules.
Migrations
migrations/*/00001_initial_schema.sql
Adds UNIQUE (name, price_cents) constraint to products table for MySQL, Postgres, and SQLite migrations to support composite conflict targets.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble code and stitch a row,
Upsert hops in where datasets go.
Make in memory, bind each field,
Conflicts decide which rows are healed.
Hooray — the rabbit’s bulk upsert is real!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add upsert method' directly and clearly describes the main change—introducing upsert functionality to the fabrique library.
Linked Issues check ✅ Passed The PR fully implements the acceptance criteria from issue #136: the upsert method is provided (UniqueBy trait, Upsert trait, and Vec impl), tested (comprehensive test suite), and documented (cookbook and concepts guides).
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing upsert functionality; no unrelated modifications are present.
Docstring Coverage ✅ Passed Docstring coverage is 95.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-upsert

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (e214ba5) to head (490384a).

Additional details and impacted files
@@            Coverage Diff            @@
##            master      #137   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           30        31    +1     
  Lines         3418      3491   +73     
=========================================
+ Hits          3418      3491   +73     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/src/concepts/factories.md`:
- Line 65: Update the example to remove the unnecessary turbofish type parameter
on Product::factory — Rust will infer the DB type from context, so replace the
explicit Product::factory::<sqlx::Sqlite>() call with a plain Product::factory()
and then chain the same builder methods (e.g., .name(...) and .make()) to match
other examples like the .make() usages in the cookbook.

In `@docs/src/concepts/models.md`:
- Line 263: Replace the call that destroys a random UUID with one that destroys
the actual record created earlier: use the created variable's id (anvil.id) when
calling Product::destroy instead of Uuid::new_v4(), so the example demonstrates
creating, operating on, and then cleaning up the same resource (reference
symbols: Product::destroy, anvil, anvil.id, Uuid::new_v4()).

In `@fabrique-core/src/upsert.rs`:
- Around line 11-25: The impl_unique_by! macro currently implements UniqueBy for
2–4 tuples (C0..C3) causing confusing errors for 5+ column composite keys;
extend support by adding additional macro invocations for higher arities (e.g.,
impl_unique_by!(C0, C1, C2, C3, C4); ... up to C7 for 8-tuples or further as
desired) so UniqueBy and its column_names() cover 5–8 element tuples, and also
add/update rustdoc on the UniqueBy trait to state the maximum supported tuple
arity (mention Column, UniqueBy, impl_unique_by!, and column_names for clarity).
- Around line 70-80: The code builds an empty update clause when all columns are
unique; guard against this by checking the computed update_cols (from
M::columns() filtered against U::column_names()) and, if it is empty, push
DB::do_nothing_sql() to qb instead of DB::do_update_sql(&update_cols); otherwise
keep the existing qb.push(DB::do_update_sql(&update_cols)). Ensure this change
is made where qb is assembled before qb.build().execute(&mut *conn).await so the
query uses DO NOTHING for empty update sets.
- Around line 27-53: The Upsert::upsert trait method must explicitly promise a
Send future and require U to be Send: change the trait method signature to
return "impl Future<Output = Result<(), crate::Error>> + Send + 'e" and add "U:
UniqueBy<Self::Model> + Send" to the where-clause; mirror those same bounds in
the Vec<M> impl (change the impl method's return type to "impl Future<Output =
Result<(), crate::Error>> + Send + 'e" and add "U: UniqueBy<M> + Send" in its
where-clause) so the trait contract and the concrete async impl are consistent
(refer to Upsert::upsert and the impl<M, DB> Upsert<DB> for Vec<M> upsert
method).

In `@fabrique-derive/src/codegen/factory.rs`:
- Around line 375-414: The duplicate logic that computes has_custom_faker,
fake_import, and the per-column column_fields in generate_factory_method_make is
identical to generate_factory_method_create; extract a private helper (e.g.,
build_fake_import_and_column_fields or prepare_field_defaults) that takes &self
and returns a tuple of TokenStreams (fake_import, column_fields) or a small
struct, implement the shared loop that inspects self.analysis.column_fields and
builds the per-field tokens and fake import there, and then call that helper
from both generate_factory_method_make and generate_factory_method_create to
replace the duplicated code paths (refer to generate_factory_method_make,
generate_factory_method_create, and the new helper name when updating callers).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 236092c4-1d23-451c-b177-ce7ea05c3a82

📥 Commits

Reviewing files that changed from the base of the PR and between e214ba5 and fbda9e7.

📒 Files selected for processing (13)
  • docs/src/concepts/factories.md
  • docs/src/concepts/models.md
  • docs/src/cookbook/bulk-update-and-upsert.md
  • fabrique-core/src/lib.rs
  • fabrique-core/src/model.rs
  • fabrique-core/src/upsert.rs
  • fabrique-derive/src/codegen/columns.rs
  • fabrique-derive/src/codegen/factory.rs
  • fabrique-derive/src/codegen/persist.rs
  • fabrique/src/lib.rs
  • fabrique/src/prelude.rs
  • fabrique/src/upsert.rs
  • fabrique/tests/upsert.rs

# price_cents: i32,
# }
# fn main() {
let product: Product = Product::factory::<sqlx::Sqlite>()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for other factory() calls without turbofish to see the pattern
rg -n 'factory\(\)' --type=md docs/ -A1 -B1

Repository: robinstraub/fabrique

Length of output: 19004


🏁 Script executed:

sed -n '60,75p' docs/src/concepts/factories.md

Repository: robinstraub/fabrique

Length of output: 439


🏁 Script executed:

rg -n 'factory.*\).*\.make\(\)' --type=md docs/ -B2 -A2

Repository: robinstraub/fabrique

Length of output: 629


🏁 Script executed:

fd -e rs 'factory' src/ | head -20

Repository: robinstraub/fabrique

Length of output: 156


🏁 Script executed:

ls -la && find . -name "*.rs" -path "*/factory*" | head -10

Repository: robinstraub/fabrique

Length of output: 1842


🏁 Script executed:

cat -n fabrique/src/factory.rs | head -100

Repository: robinstraub/fabrique

Length of output: 3543


🏁 Script executed:

rg -n 'fn make\(' fabrique/src/factory.rs -A 5

Repository: robinstraub/fabrique

Length of output: 46


🏁 Script executed:

rg -n 'fn make\(' fabrique-core/src/factory.rs -A 5

Repository: robinstraub/fabrique

Length of output: 46


🏁 Script executed:

rg -n 'make' fabrique/src/factory.rs | head -20

Repository: robinstraub/fabrique

Length of output: 46


🏁 Script executed:

wc -l fabrique/src/factory.rs && tail -100 fabrique/src/factory.rs

Repository: robinstraub/fabrique

Length of output: 2680


🏁 Script executed:

cat -n fabrique-core/src/factory.rs | head -150

Repository: robinstraub/fabrique

Length of output: 3086


🏁 Script executed:

cat -n fabrique-derive/src/codegen/factory.rs | head -200

Repository: robinstraub/fabrique

Length of output: 8993


🏁 Script executed:

rg -n 'fn generate_factory_method_make' fabrique-derive/src/codegen/factory.rs -A 30

Repository: robinstraub/fabrique

Length of output: 1146


🏁 Script executed:

rg -n 'fn generate_factory_method_make' fabrique-derive/src/codegen/factory.rs -A 50 | head -70

Repository: robinstraub/fabrique

Length of output: 1955


Remove the unnecessary turbofish type parameter.

The example uses Product::factory::<sqlx::Sqlite>() but this explicit type specification is redundant. Rust can infer the database type from context. Other .make() examples in the docs (e.g., docs/src/cookbook/bulk-update-and-upsert.md lines 217–218) omit the turbofish and compile successfully. Update to match the consistent pattern elsewhere:

let product: Product = Product::factory()
    .name("Anvil 3000".to_string())
    .make();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/src/concepts/factories.md` at line 65, Update the example to remove the
unnecessary turbofish type parameter on Product::factory — Rust will infer the
DB type from context, so replace the explicit Product::factory::<sqlx::Sqlite>()
call with a plain Product::factory() and then chain the same builder methods
(e.g., .name(...) and .make()) to match other examples like the .make() usages
in the cookbook.


// Delete by primary key, no instance needed
Product::destroy(&pool, anvil.id).await?;
Product::destroy(&pool, Uuid::new_v4()).await?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there are similar destroy patterns in other docs
rg -n -C3 '::destroy\(' --type=md docs/

Repository: robinstraub/fabrique

Length of output: 2577


🏁 Script executed:

# Get the full context around line 263 in models.md
sed -n '240,280p' docs/src/concepts/models.md

Repository: robinstraub/fabrique

Length of output: 920


Update destroy example to delete the created record for proper lifecycle demonstration.

The example creates an anvil record and demonstrates multiple operations on it, but then deletes a random Uuid::new_v4() instead of the anvil.id. This breaks the expected pattern of creating, using, and cleaning up the same resource. While the comment "no instance needed" suggests this was intentional to show the API capability, the same point can be demonstrated by using anvil.id—which actually destroys the record that was created—rather than a non-existent UUID. This provides a complete and realistic lifecycle example.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/src/concepts/models.md` at line 263, Replace the call that destroys a
random UUID with one that destroys the actual record created earlier: use the
created variable's id (anvil.id) when calling Product::destroy instead of
Uuid::new_v4(), so the example demonstrates creating, operating on, and then
cleaning up the same resource (reference symbols: Product::destroy, anvil,
anvil.id, Uuid::new_v4()).

Comment on lines +11 to +25
macro_rules! impl_unique_by {
($($C:ident),+) => {
impl<M, $($C),+> UniqueBy<M> for ($($C,)+)
where
$($C: Column<M>,)+
{
fn column_names() -> &'static [&'static str] {
&[$($C::NAME),+]
}
}
};
}
impl_unique_by!(C0, C1);
impl_unique_by!(C0, C1, C2);
impl_unique_by!(C0, C1, C2, C3);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Tuple arity capped at 4 — document or extend.

impl_unique_by! only covers 2-, 3-, and 4-tuples. A user attempting a 5+-column composite conflict target (rare but valid for natural keys) will hit a confusing trait-bound error rather than a clear message. Single-column targets are covered by the per-column codegen, so that gap is intentional.

Either extend the macro to a higher arity (e.g., up to 8 or 12, mirroring std's tuple-trait conventions) or note the limit in the trait's rustdoc and in the cookbook section.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fabrique-core/src/upsert.rs` around lines 11 - 25, The impl_unique_by! macro
currently implements UniqueBy for 2–4 tuples (C0..C3) causing confusing errors
for 5+ column composite keys; extend support by adding additional macro
invocations for higher arities (e.g., impl_unique_by!(C0, C1, C2, C3, C4); ...
up to C7 for 8-tuples or further as desired) so UniqueBy and its column_names()
cover 5–8 element tuples, and also add/update rustdoc on the UniqueBy trait to
state the maximum supported tuple arity (mention Column, UniqueBy,
impl_unique_by!, and column_names for clarity).

Comment thread fabrique-core/src/upsert.rs
Comment on lines +70 to +80
let unique_cols = U::column_names();
qb.push(DB::on_conflict_sql(unique_cols));

let update_cols: Vec<&str> = M::columns()
.iter()
.copied()
.filter(|c| !unique_cols.contains(c))
.collect();
qb.push(DB::do_update_sql(&update_cols));

qb.build().execute(&mut *conn).await?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find Dialect trait definition and impls for on_conflict_sql / do_update_sql
# to verify behavior when an empty slice is passed.
fd -e rs | xargs rg -nP -C5 '\bdo_update_sql\s*\(' 
echo "---"
fd -e rs | xargs rg -nP -C5 '\bon_conflict_sql\s*\('
echo "---"
# Look for any explicit empty-slice guard
fd -e rs | xargs rg -nP -C2 'is_empty\(\)|len\(\)\s*==\s*0' | rg -i 'do_update|conflict'

Repository: robinstraub/fabrique

Length of output: 13107


🏁 Script executed:

sed -n '40,50p' fabrique-core/src/dialect.rs

Repository: robinstraub/fabrique

Length of output: 438


🏁 Script executed:

sed -n '68,78p' fabrique-core/src/dialect.rs

Repository: robinstraub/fabrique

Length of output: 408


🏁 Script executed:

sed -n '96,106p' fabrique-core/src/dialect.rs

Repository: robinstraub/fabrique

Length of output: 408


🏁 Script executed:

# Also check the complete upsert function to see context
sed -n '60,85p' fabrique-core/src/upsert.rs

Repository: robinstraub/fabrique

Length of output: 743


Guard against empty update_cols slice.

When all columns are part of the conflict target, update_cols becomes empty. The current dialect implementations generate incomplete SQL for this case:

  • Postgres/Sqlite: " DO UPDATE SET " (incomplete SET clause)
  • MySql: " ON DUPLICATE KEY UPDATE " (incomplete UPDATE clause)

All three produce syntax errors. The semantically correct behavior is DO NOTHING.

Add a guard at line 78 to call DB::do_nothing_sql() when update_cols.is_empty():

if update_cols.is_empty() {
    qb.push(DB::do_nothing_sql());
} else {
    qb.push(DB::do_update_sql(&update_cols));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fabrique-core/src/upsert.rs` around lines 70 - 80, The code builds an empty
update clause when all columns are unique; guard against this by checking the
computed update_cols (from M::columns() filtered against U::column_names()) and,
if it is empty, push DB::do_nothing_sql() to qb instead of
DB::do_update_sql(&update_cols); otherwise keep the existing
qb.push(DB::do_update_sql(&update_cols)). Ensure this change is made where qb is
assembled before qb.build().execute(&mut *conn).await so the query uses DO
NOTHING for empty update sets.

Comment on lines +375 to +414
/// Generates the `make()` method that builds a model instance
/// without persisting it.
fn generate_factory_method_make(&self) -> TokenStream {
let struct_ident = &self.analysis.ident;

let has_custom_faker = self
.analysis
.column_fields
.iter()
.any(|f| f.faker.is_some());

let fake_import = if has_custom_faker {
quote! { use ::fabrique::fake::Fake; }
} else {
quote! {}
};

let column_fields = self.analysis.column_fields.iter().map(|field| {
let name = &field.ident;
let ty = &field.ty;

match &field.faker {
Some(faker_expr) => quote! {
#name: self.#name.unwrap_or_else(|| #faker_expr.fake())
},
None => quote! {
#name: self.#name.unwrap_or_else(::fabrique::seeded_value::<#ty>)
},
}
});

quote! {
pub fn make(self) -> #struct_ident {
#fake_import
#struct_ident {
#(#column_fields,)*
}
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Deduplicate field-construction logic shared with generate_factory_method_create.

has_custom_faker, fake_import, and the per-column column_fields mapping at lines 380-404 are a verbatim copy of lines 286-314 in generate_factory_method_create. Any future change to the defaulting strategy (new field attributes, alternate seed source, etc.) will need to be applied in both places, and these two paths will silently drift on the first miss.

Extract a small helper that returns the shared (fake_import, column_fields) token streams and call it from both methods.

♻️ Sketch of a shared helper
+    /// Shared field-construction tokens used by both `create()` and
+    /// `make()`. Returns `(fake_import, column_field_initializers)`.
+    fn generate_field_initializers(
+        &self,
+    ) -> (TokenStream, Vec<TokenStream>) {
+        let has_custom_faker = self
+            .analysis
+            .column_fields
+            .iter()
+            .any(|f| f.faker.is_some());
+
+        let fake_import = if has_custom_faker {
+            quote! { use ::fabrique::fake::Fake; }
+        } else {
+            quote! {}
+        };
+
+        let column_fields = self
+            .analysis
+            .column_fields
+            .iter()
+            .map(|field| {
+                let name = &field.ident;
+                let ty = &field.ty;
+                match &field.faker {
+                    Some(faker_expr) => quote! {
+                        `#name`: self.#name.unwrap_or_else(|| `#faker_expr.fake`())
+                    },
+                    None => quote! {
+                        `#name`: self.#name.unwrap_or_else(::fabrique::seeded_value::<#ty>)
+                    },
+                }
+            })
+            .collect();
+
+        (fake_import, column_fields)
+    }
+
     fn generate_factory_method_make(&self) -> TokenStream {
         let struct_ident = &self.analysis.ident;
-
-        let has_custom_faker = self
-            .analysis
-            .column_fields
-            .iter()
-            .any(|f| f.faker.is_some());
-
-        let fake_import = if has_custom_faker {
-            quote! { use ::fabrique::fake::Fake; }
-        } else {
-            quote! {}
-        };
-
-        let column_fields = self.analysis.column_fields.iter().map(|field| { /* ... */ });
+        let (fake_import, column_fields) = self.generate_field_initializers();

         quote! {
             pub fn make(self) -> `#struct_ident` {
                 `#fake_import`
                 `#struct_ident` {
                     #(`#column_fields`,)*
                 }
             }
         }
     }

Then mirror the same call inside generate_factory_method_create in place of lines 286-314.

As per coding guidelines on essential refactors: code smells such as duplicate code (copy/paste, similar logic) should be removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fabrique-derive/src/codegen/factory.rs` around lines 375 - 414, The duplicate
logic that computes has_custom_faker, fake_import, and the per-column
column_fields in generate_factory_method_make is identical to
generate_factory_method_create; extract a private helper (e.g.,
build_fake_import_and_column_fields or prepare_field_defaults) that takes &self
and returns a tuple of TokenStreams (fake_import, column_fields) or a small
struct, implement the shared loop that inspects self.analysis.column_fields and
builds the per-field tokens and fake import there, and then call that helper
from both generate_factory_method_make and generate_factory_method_create to
replace the duplicated code paths (refer to generate_factory_method_make,
generate_factory_method_create, and the new helper name when updating callers).

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
fabrique-derive/src/codegen/factory.rs (1)

380-404: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Extract shared faker/default field token generation to avoid drift.

This block is duplicated from generate_factory_method_create and will drift on the next change to defaulting/faker behavior. Please centralize it in a private helper and reuse in both methods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fabrique-derive/src/codegen/factory.rs` around lines 380 - 404, The
duplication builds the same faker/default tokens (has_custom_faker, fake_import,
and column_fields iteration) used in generate_factory_method_create; extract
that logic into a private helper method (e.g., a method on the same impl like
shared_faker_tokens or build_faker_tokens) that takes &self (or &self.analysis)
and returns the fake_import token and the column_fields token stream/iterator so
both generate_factory_method_create and the other caller reuse it; replace the
duplicated block with a call to that helper and use its returned tokens,
preserving existing behavior for field.faker, ::fabrique::seeded_value, and the
Fake trait usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fabrique-derive/src/codegen/factory.rs`:
- Around line 407-411: The make() implementation currently only constructs
`#struct_ident` from `#column_fields` and ignores any stored relation values (fields
named like *_relation), causing for_<relation>() setters to be no-ops; update
make() to consume each relation field (e.g., fields matching /_relation$/) and
apply them to the resulting struct by converting the relation into its foreign
key columns (or using the relation's primary key) so FK fields are set from the
provided relation instead of seeded defaults; locate the make() method and add
logic that checks each *_relation field, extracts the needed key(s) (or calls
the relation's make/into_key helper), and override the corresponding FK column
entries in the built `#struct_ident` accordingly.

In `@fabrique/tests/upsert.rs`:
- Around line 101-105: The test currently asserts that
products.clone().upsert(&pool, (Product::ID, Product::NAME)).await returns an
error, but that expectation is dialect-dependent; update the test to branch on
the DB dialect (using the test helper or pool/dialect accessor available in your
test harness) and only assert is_err() for dialects that use ON CONFLICT (e.g.,
Postgres/SQLite), while for MySQL assert the call succeeds or matches MySQL's
expected behavior. Locate the upsert call in the test (products.upsert,
Product::ID/Product::NAME, pool) and add a small conditional: detect MySQL and
assert the opposite (or skip) otherwise assert is_err().

---

Duplicate comments:
In `@fabrique-derive/src/codegen/factory.rs`:
- Around line 380-404: The duplication builds the same faker/default tokens
(has_custom_faker, fake_import, and column_fields iteration) used in
generate_factory_method_create; extract that logic into a private helper method
(e.g., a method on the same impl like shared_faker_tokens or build_faker_tokens)
that takes &self (or &self.analysis) and returns the fake_import token and the
column_fields token stream/iterator so both generate_factory_method_create and
the other caller reuse it; replace the duplicated block with a call to that
helper and use its returned tokens, preserving existing behavior for
field.faker, ::fabrique::seeded_value, and the Fake trait usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ff214748-9bcd-4d0f-94e8-d755c635d135

📥 Commits

Reviewing files that changed from the base of the PR and between fbda9e7 and 587b56a.

📒 Files selected for processing (3)
  • fabrique-core/src/upsert.rs
  • fabrique-derive/src/codegen/factory.rs
  • fabrique/tests/upsert.rs

Comment on lines +407 to +411
pub fn make(self) -> #struct_ident {
#fake_import
#struct_ident {
#(#column_fields,)*
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

make() ignores for_<relation>() inputs, causing inconsistent factory behavior.

make() only materializes column_fields and never consumes *_relation; so relation setters become no-ops on this path, and FK fields may be seeded instead of using caller-provided relations. This is a functional gap versus the fluent API surface.

Suggested direction
 pub fn make(mut self) -> `#struct_ident` {
+    // Resolve belongs_to relation fields into FK columns for in-memory build.
+    // - PrimaryKey variant: assign FK directly.
+    // - Factory variant: return an error or panic with a clear message since no DB executor exists.
+    //   (Preferably adjust make() signature to Result<..., fabrique::Error> if you want non-panicking behavior.)
+
     `#fake_import`
     `#struct_ident` {
         #(`#column_fields`,)*
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fabrique-derive/src/codegen/factory.rs` around lines 407 - 411, The make()
implementation currently only constructs `#struct_ident` from `#column_fields` and
ignores any stored relation values (fields named like *_relation), causing
for_<relation>() setters to be no-ops; update make() to consume each relation
field (e.g., fields matching /_relation$/) and apply them to the resulting
struct by converting the relation into its foreign key columns (or using the
relation's primary key) so FK fields are set from the provided relation instead
of seeded defaults; locate the make() method and add logic that checks each
*_relation field, extracts the needed key(s) (or calls the relation's
make/into_key helper), and override the corresponding FK column entries in the
built `#struct_ident` accordingly.

Comment thread fabrique/tests/upsert.rs Outdated
Comment on lines +101 to +105
let result = products
.clone()
.upsert(&pool, (Product::ID, Product::NAME))
.await;
assert!(result.is_err());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify dialect-specific upsert SQL behavior and feature flags before applying cfg gating.

# 1) Inspect Dialect impls for conflict SQL shape.
fd -e rs | xargs rg -nP -C3 'impl\s+Dialect\s+for\s+(Postgres|Sqlite|MySql)|fn\s+on_conflict_sql|fn\s+do_update_sql'

# 2) Confirm actual feature names used by this workspace.
rg -nP -C2 '^\[features\]|postgres|sqlite|mysql' -- */Cargo.toml Cargo.toml

Repository: robinstraub/fabrique

Length of output: 4885


🏁 Script executed:

# Read the test file around lines 101-105
head -120 fabrique/tests/upsert.rs | tail -30

Repository: robinstraub/fabrique

Length of output: 891


🏁 Script executed:

# Check if the test file uses any dialect-conditional compilation
rg -n '#\[cfg\(' fabrique/tests/upsert.rs

Repository: robinstraub/fabrique

Length of output: 99


🏁 Script executed:

# Look at the full upsert.rs test context, focusing on the Product struct and test setup
head -110 fabrique/tests/upsert.rs

Repository: robinstraub/fabrique

Length of output: 2879


Make the invalid-composite assertion dialect-aware.

Lines 101–105 test that upserting with (Product::ID, Product::NAME) must error, but this behavior is dialect-dependent. MySQL's on_conflict_sql() returns an empty string (using different INSERT ... ON DUPLICATE KEY UPDATE semantics), while Postgres and SQLite use ON CONFLICT clauses. The test will fail incorrectly on MySQL when that feature is enabled.

Suggested change
     let result = products
         .clone()
         .upsert(&pool, (Product::ID, Product::NAME))
         .await;
-    assert!(result.is_err());
+    #[cfg(any(feature = "postgres", feature = "sqlite"))]
+    assert!(result.is_err());
+    #[cfg(feature = "mysql")]
+    assert!(result.is_ok());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let result = products
.clone()
.upsert(&pool, (Product::ID, Product::NAME))
.await;
assert!(result.is_err());
let result = products
.clone()
.upsert(&pool, (Product::ID, Product::NAME))
.await;
#[cfg(any(feature = "postgres", feature = "sqlite"))]
assert!(result.is_err());
#[cfg(feature = "mysql")]
assert!(result.is_ok());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fabrique/tests/upsert.rs` around lines 101 - 105, The test currently asserts
that products.clone().upsert(&pool, (Product::ID, Product::NAME)).await returns
an error, but that expectation is dialect-dependent; update the test to branch
on the DB dialect (using the test helper or pool/dialect accessor available in
your test harness) and only assert is_err() for dialects that use ON CONFLICT
(e.g., Postgres/SQLite), while for MySQL assert the call succeeds or matches
MySQL's expected behavior. Locate the upsert call in the test (products.upsert,
Product::ID/Product::NAME, pool) and add a small conditional: detect MySQL and
assert the opposite (or skip) otherwise assert is_err().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

upsert support

1 participant