From 8099decbfad58d3d6b273ef7dce97ebec6b50a84 Mon Sep 17 00:00:00 2001 From: Alex Takitani Date: Sat, 3 Oct 2026 20:34:25 -0300 Subject: [PATCH] A has_many :through writer on a persisted owner writes its join rows at once The synthesized `tags=` only staged the collection and marked it stale for `_sync_tags` in after_save. Rails' collection writer writes the join rows immediately when the owner is persisted, and defers only for a new record. So `entry.save!` followed by `entry.tags = tags` left the tags in memory, answered as if assigned, and wrote no join row. The writer now calls `_sync_` when `self.persisted?`; a new record still syncs at save. Co-Authored-By: Claude Opus 5.5 --- src/lower/model_to_library/associations.rs | 37 ++++++++++++++++ tests/through_writer_persisted_owner.rs | 50 ++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/through_writer_persisted_owner.rs diff --git a/src/lower/model_to_library/associations.rs b/src/lower/model_to_library/associations.rs index 9e3deed06..f58af0e29 100644 --- a/src/lower/model_to_library/associations.rs +++ b/src/lower/model_to_library/associations.rs @@ -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") }`). @@ -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())), + 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, diff --git a/tests/through_writer_persisted_owner.rs b/tests/through_writer_persisted_owner.rs new file mode 100644 index 000000000..71695f331 --- /dev/null +++ b/tests/through_writer_persisted_owner.rs @@ -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(); +}