Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/lower/model_to_library/associations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1326,8 +1326,15 @@ fn synth_belongs_to_writer(
/// @tags_cache = values.to_a
/// @tags_loaded = true
/// @tags_stale = true
/// if self.persisted? then self._sync_tags end
/// nil
/// end
///
/// A persisted owner writes its join rows at once, as Rails does; a new
/// one keeps them staged until `after_save`. Staged-only, code that saves
/// a record and THEN assigns (`entry.save!; entry.tags = tags`) never
/// wrote a join row.
///
/// `.to_a` because the argument is as often a Relation as an Array
/// (lobsters: `self.tags = Tag.where(tag: final_tags)` in Story, and
/// the story factory's `tags { Tag.where(tag: "placeholder") }`).
Expand Down Expand Up @@ -1368,6 +1375,36 @@ fn synth_through_collection_writer(owner: &ClassId, name: &Symbol, target: &Clas
),
ivar_assign(format!("{}_loaded", name.as_str()), bool_lit(true)),
ivar_assign(format!("{}_stale", name.as_str()), bool_lit(true)),
// Persisted owner → write the join rows NOW (Rails' collection
// writer does; only a new record defers to its save). Same
// `self.persisted?` spelling markers.rs uses, for the same reason.
Expr::new(
Span::synthetic(),
ExprNode::If {
cond: Expr::new(
Span::synthetic(),
ExprNode::Send {
recv: Some(Expr::new(Span::synthetic(), ExprNode::SelfRef)),
method: Symbol::from("persisted?"),
args: vec![],
block: None,
parenthesized: false,
},
),
then_branch: Expr::new(
Span::synthetic(),
ExprNode::Send {
recv: Some(Expr::new(Span::synthetic(), ExprNode::SelfRef)),
method: Symbol::from(format!("_sync_{}", name.as_str())),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
args: vec![],
block: None,
parenthesized: false,
},
),
else_branch: nil_lit(),
},
),
Expr::new(Span::synthetic(), ExprNode::Lit { value: Literal::Nil }),
]);
MethodDef {
visibility: crate::dialect::MethodVisibility::Public,
Expand Down
50 changes: 50 additions & 0 deletions tests/through_writer_persisted_owner.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//! Emitted-program regression for this fix (kept out of tests/emit_and_run.rs
//! so concurrent appends there do not conflict). Same harness.

#[path = "support/emit_and_run.rs"]
mod emit_and_run;

/// `record.tags = [...]` on a PERSISTED owner writes the join rows at once,
/// as Rails' `has_many :through` collection writer does (only a new record
/// defers them to its save). The synthesized writer only staged them for
/// `_sync_tags` in `after_save`, so code that saves a record and THEN
/// assigns its tags (`entry.save!; entry.tags = tags`) answered with the
/// tags in memory while no join row was ever written. Silent loss.
#[test]
fn a_through_collection_writer_on_a_persisted_owner_writes_at_once() {
emit_and_run::real_blog()
.edit(
"db/schema.rb",
" create_table \"articles\", force: :cascade do |t|",
" create_table \"labels\", force: :cascade do |t|\n t.string \"name\"\n t.datetime \"created_at\", null: false\n t.datetime \"updated_at\", null: false\n end\n\n create_table \"labelings\", force: :cascade do |t|\n t.integer \"article_id\", null: false\n t.integer \"label_id\", null: false\n t.datetime \"created_at\", null: false\n t.datetime \"updated_at\", null: false\n end\n\n create_table \"articles\", force: :cascade do |t|",
)
.write("app/models/label.rb", "class Label < ApplicationRecord\nend\n")
.write(
"app/models/labeling.rb",
"class Labeling < ApplicationRecord\n belongs_to :article\n belongs_to :label\nend\n",
)
.edit(
"app/models/article.rb",
"class Article < ApplicationRecord\n",
"class Article < ApplicationRecord\n has_many :labelings, dependent: :destroy\n has_many :labels, through: :labelings\n",
)
.run_ruby(r#"
Labeling.delete_all
Article.delete_all
Label.delete_all
a = Label.create!(name: "a")
b = Label.create!(name: "b")
saved = Article.create!(title: "Persisted", body: "Long enough body")
saved.labels = [a, b]
raise "persisted owner: #{Labeling.count} join rows, want 2" unless Labeling.count == 2
saved.labels = [b]
raise "replace: #{Labeling.count} join rows, want 1" unless Labeling.count == 1
fresh = Article.new(title: "Fresh", body: "Long enough body")
fresh.labels = [a]
raise "new owner wrote early" unless Labeling.count == 1
fresh.save!
raise "new owner after save: #{Labeling.count}, want 2" unless Labeling.count == 2
puts "through writer ok"
"#)
.assert_passes();
}