-
Notifications
You must be signed in to change notification settings - Fork 22k
Do not stringify attributes in assign_attributes #38401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Do not stringify attributes in assign_attributes #38401
Conversation
@rafaelfranca please let me know what you think. I believe this addresses the issues in my previous attempt, while keeping the performance enhancement. |
super(new_attributes) | ||
|
||
assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty? | ||
assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some other versions in no particular order.
Take 1:
def _assign_attributes(attributes)
multi_parameter_attributes = {}
nested_parameter_attributes = {}
super \
attributes.dup.delete_if do |key, value|
key = key.to_s
if key.include?("(")
multi_parameter_attributes[key] = value
elsif value.is_a?(Hash)
nested_parameter_attributes[key] = value
end
end
assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty?
assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty?
end
Take 2:
def _assign_attributes(attributes)
multi_parameter_attributes = {}
nested_parameter_attributes = {}
new_attributes = {}
attributes.each do |key, value|
key = key.to_s
hash = multi_parameter_attributes if key.include?("(")
hash ||= nested_parameter_attributes if value.is_a?(Hash)
hash ||= new_attributes
hash[key] = value
end
super(new_attributes)
assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty?
assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty?
end
Take 1 taken a little further with the extra idea that nested or multi parameter attributes are passed rarely:
def _assign_attributes(attributes)
multi_parameter_attributes = nested_parameter_attributes = nil
super \
attributes.dup.delete_if do |key, value|
key = key.to_s
if key.include?("(")
(multi_parameter_attributes ||= {})[key] = value
elsif value.is_a?(Hash)
(nested_parameter_attributes ||= {})[key] = value
end
end
assign_nested_parameter_attributes(nested_parameter_attributes) if nested_parameter_attributes&.any?
assign_multiparameter_attributes(multi_parameter_attributes) if multi_parameter_attributes&.any?
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally, I like take 1 better.
EDIT: I played around with take 1, but we need to return true or false inside the delete_if
block to make sure the correct attributes are removed from the dupped hash. In the end, it looks a little messier than the current implementation.
def _assign_attributes(attributes) | ||
multi_parameter_attributes = {} | ||
nested_parameter_attributes = {} | ||
new_attributes = attributes.dup |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why just no use attributes.stringify_keys
?
new_attributes = attributes.dup | |
attributes = attributes.stringify_keys |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't that loop through the attributes twice? Once for turning the keys into strings and another one in the each
block (which is precisely what I attempted to avoid in the PR).
Personally I'd not prefer this change since this makes rails/activerecord/lib/active_record/scoping.rb Lines 35 to 40 in 073d25d
rails/activerecord/lib/active_record/associations/association.rb Lines 183 to 191 in 073d25d
|
@kamipo I agree that if we call I can benchmark these two methods and check the differences. |
Benchmarking
|
Benchmark for
|
I think @kamipo's main objection is that diff --git a/activerecord/lib/active_record/attribute_assignment.rb b/activerecord/lib/active_record/attribute_assignment.rb
index cd14e66c5b..facdac2380 100644
--- a/activerecord/lib/active_record/attribute_assignment.rb
+++ b/activerecord/lib/active_record/attribute_assignment.rb
@@ -6,11 +6,14 @@ module ActiveRecord
module AttributeAssignment
include ActiveModel::AttributeAssignment
+ def assign_attributes(new_attributes)
+ super(new_attributes.dup)
+ end
+
private
- def _assign_attributes(attributes)
+ def _assign_attributes(new_attributes)
multi_parameter_attributes = {}
nested_parameter_attributes = {}
- new_attributes = attributes.dup
new_attributes.each do |k, v|
key = k.to_s We're still calling |
Can you show the impact of this change on Active Record's |
I've benchmarked one method that makes use of I had to run the script twice separately for the patched and the non-patched version (since having two version of Let me know if you'd like to see other benchmarks and other means of making sure that the change is desired. Script# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails", github: "rails/rails", require: "rails/all"
gem "benchmark-ips"
gem "sqlite3"
end
module ActiveModel
module AttributeAssignment
def fast_assign_attributes(new_attributes)
unless new_attributes.respond_to?(:each_pair)
raise ArgumentError, "When assigning attributes, you must pass a hash as an argument, #{new_attributes.class} passed."
end
return if new_attributes.empty?
_fast_assign_attributes(sanitize_for_mass_assignment(new_attributes))
end
alias attributes= assign_attributes
private
def _fast_assign_attributes(attributes)
attributes.each do |k, v|
_fast_assign_attribute(k, v)
end
end
def _fast_assign_attribute(k, v)
setter = :"#{k}="
if respond_to?(setter)
public_send(setter, v)
else
raise UnknownAttributeError.new(self, k.to_s)
end
end
end
end
module ActiveRecord
module AttributeAssignment
def fast_assign_attributes(attributes)
super(attributes.dup)
end
def _fast_assign_attributes(attributes)
multi_parameter_attributes = {}
nested_parameter_attributes = {}
attributes.each do |k, v|
key = k.to_s
if key.include?("(")
multi_parameter_attributes[key] = attributes.delete(k)
elsif v.is_a?(Hash)
nested_parameter_attributes[key] = attributes.delete(k)
end
end
super(attributes)
assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty?
assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty?
end
end
module Core
def initialize(attributes = nil)
@new_record = true
@attributes = self.class._default_attributes.deep_dup
init_internals
initialize_internals_callback
assign_attributes(attributes) if attributes
yield self if block_given?
_run_initialize_callbacks
end
end
end
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
create_table :posts, force: true do |t|
t.string :title
t.string :author
t.boolean :published
t.integer :score
t.string :tags
t.text :content
end
create_table :comments, force: true do |t|
t.text :content
t.references :post
end
end
class Post < ActiveRecord::Base
include ActiveModel::AttributeAssignment
attr_accessor :title, :author, :published, :score, :tags, :content
has_many :comments
end
class Comment < ActiveRecord::Base
belongs_to :post
end
attributes = {
title: "My post",
author: "John",
published: true,
score: 500,
tags: "Software development,Web development",
content: "random text" * 100
}
Benchmark.ips do |x|
x.report("initialize") { Post.new(attributes) }
x.compare!
end Results
|
Can we measure |
@rafaelfranca good call, the more symbols used for attributes the slower Benchmark# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails", "6.0.2.1", require: "rails/all"
gem "benchmark-ips"
gem "sqlite3"
end
module ActiveModel
module AttributeAssignment
def fast_assign_attributes(new_attributes)
unless new_attributes.respond_to?(:each_pair)
raise ArgumentError, "When assigning attributes, you must pass a hash as an argument, #{new_attributes.class} passed."
end
return if new_attributes.empty?
_fast_assign_attributes(sanitize_for_mass_assignment(new_attributes))
end
alias attributes= assign_attributes
private
def _fast_assign_attributes(attributes)
attributes.each do |k, v|
_fast_assign_attribute(k, v)
end
end
def _fast_assign_attribute(k, v)
setter = :"#{k}="
if respond_to?(setter)
public_send(setter, v)
else
raise UnknownAttributeError.new(self, k.to_s)
end
end
end
end
module ActiveRecord
module AttributeAssignment
def fast_assign_attributes(attributes)
super(attributes.dup)
end
def _fast_assign_attributes(attributes)
multi_parameter_attributes = {}
nested_parameter_attributes = {}
attributes.each do |k, v|
key = k.to_s
if key.include?("(")
multi_parameter_attributes[key] = attributes.delete(k)
elsif v.is_a?(Hash)
nested_parameter_attributes[key] = attributes.delete(k)
end
end
super(attributes)
assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty?
assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty?
end
end
end
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
create_table :posts, force: true do |t|
t.string :title
t.string :author
t.boolean :published
t.integer :score
t.string :tags
t.text :content
t.integer :another_field
end
create_table :comments, force: true do |t|
t.text :content
t.references :post
end
end
class Post < ActiveRecord::Base
include ActiveModel::AttributeAssignment
attr_accessor :title, :author, :published, :score, :tags, :content, :another_field
has_many :comments
end
class Comment < ActiveRecord::Base
belongs_to :post
end
SCENARIOS = {
"All symbols" => {
title: "My post",
author: "John",
published: true,
score: 500,
tags: "Software development,Web development",
content: "random text" * 100,
another_field: 55555
},
"Mixed" => {
"title" => "My post",
author: "John",
"published" => true,
score: 500,
"tags" => "Software development,Web development",
content: "random text" * 100,
"another_field" => 55555
},
"All strings" => {
"title" => "My post",
"author" => "John",
"published" => true,
"score" => 500,
"tags" => "Software development,Web development",
"content" => "random text" * 100,
"another_field" => 55555
}
}
SCENARIOS.each_pair do |name, value|
puts
puts " #{name} ".center(80, "=")
puts
string_attributes = value.stringify_keys
post = Post.new
Benchmark.ips do |x|
x.report("assign_attributes") { post.send(:_assign_attributes, string_attributes) }
x.report("fast_assign_attributes") { post.send(:_fast_assign_attributes, value) }
x.compare!
end
end Results
|
The "All strings" case is the one we care about, since there is no difference I'm merging this PR. |
Summary
This PR switches from stringifying attributes early on and invokes
to_s
whenever necessary within the processing loops.I also took the opportunity to change
if !condition
tounless condition
.Benchmark script
Results