`upsert_all` writes default scope attributes into the UPDATE clause
Steps to reproduce
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails"
# If you want to test against edge Rails replace the previous line with this:
# gem "rails", github: "rails/rails", branch: "main"
gem "sqlite3"
end
require "active_record/railtie"
require "minitest/autorun"
ENV["DATABASE_URL"] = "sqlite3::memory:"
class TestApp < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.eager_load = false
config.logger = Logger.new($stdout)
config.secret_key_base = "secret_key_base"
end
Rails.application.initialize!
ActiveRecord::Schema.define do
create_table :posts, force: true do |t|
t.string :title
t.datetime :deleted_at
end
end
class Post < ActiveRecord::Base
default_scope { where(deleted_at: nil) }
end
class BugTest < ActiveSupport::TestCase
def test_upsert_all_does_not_write_the_default_scope_column
ActiveRecord::Base.connection.execute(
"INSERT INTO posts (id, title, deleted_at) VALUES (1, 'old', '2020-01-01 00:00:00')"
)
Post.upsert_all([{ id: 1, title: "new" }])
post = Post.unscoped.find(1)
assert_equal "new", post.title
assert_not_nil post.deleted_at
end
end
Expected behavior
The upsert updates title and leaves deleted_at alone. Nothing in the payload mentions deleted_at.
Actual behavior
The soft deleted row is restored:
INSERT INTO "posts" ("id","title","deleted_at") VALUES (1, 'new', NULL)
ON CONFLICT ("id") DO UPDATE SET "title"=excluded."title","deleted_at"=excluded."deleted_at"
InsertAll#initialize merges relation.scope_for_create into @keys, and updatable_columns is keys - readonly_columns - unique_by_columns, so the scope keys survive into the SET list the adapters build.
On the INSERT branch that matches documented behaviour (Article.new.published # => true). On the UPDATE branch it contradicts it, since default_scope "is not applied while updating or deleting a record" (active_record/scoping/default.rb). update_all on the same relation puts the scope in WHERE and never in SET, and neither save nor update_columns touches the column.
This is not a regression, I get the same SQL on 6.1.7.9, 7.0.8.5 and 8.1.3.1. The old if model.scope_attributes? guard reads like it excluded default scopes, but Scoping::Default::ClassMethods#scope_attributes? is super || default_scopes.any?, so a declared default_scope always satisfied it.
update_only: works around it. A fix needs both halves: dropping from updatable_columns the scope keys the caller did not pass, and the precedence change in #44316 so that a payload built from record.as_json, which does carry the column, keeps its own value. Either half alone still leaves one of the two payload shapes broken. Association keys need to stay an exception and keep being forced onto every row, the way Associations::Association#initialize_attributes forces them, otherwise author.books.upsert_all stops stamping author_id. I have that patched and will open a PR.
System configuration
Rails version: 8.1.3.1, also reproduced on 7.0.8.5 and 6.1.7.9
Ruby version: 3.4.7
Source: rails/rails