Skip to content

Commit be9b680

Browse files
committed
Introduce ActiveRecord::Base#accessed_fields
This method can be used to see all of the fields on a model which have been read. This can be useful during development mode to quickly find out which fields need to be selected. For performance critical pages, if you are not using all of the fields of a database, an easy performance win is only selecting the fields which you need. By calling this method at the end of a controller action, it's easy to determine which fields need to be selected. While writing this, I also noticed a place for an easy performance win internally which I had been wanting to introduce. You cannot mutate a field which you have not read. Therefore, we can skip the calculation of in place changes if we have never read from the field. This can significantly speed up methods like `#changed?` if any of the fields have an expensive mutable type (like `serialize`) ``` Calculating ------------------------------------- #changed? with serialized column (before) 391.000 i/100ms #changed? with serialized column (after) 1.514k i/100ms ------------------------------------------------- #changed? with serialized column (before) 4.243k (± 3.7%) i/s - 21.505k #changed? with serialized column (after) 16.789k (± 3.2%) i/s - 84.784k ```
1 parent 08fe700 commit be9b680

File tree

7 files changed

+88
-1
lines changed

7 files changed

+88
-1
lines changed

‎activerecord/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
* Add `ActiveRecord::Base#accessed_fields`, which can be used to quickly
2+
discover which fields were read from a model when you are looking to only
3+
select the data you need from the database.
4+
5+
*Sean Griffin*
6+
17
* Introduce the `:if_exists` option for `drop_table`.
28

39
Example:

‎activerecord/lib/active_record/attribute.rb‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def changed_from?(old_value)
5151
end
5252

5353
def changed_in_place_from?(old_value)
54-
type.changed_in_place?(old_value, value)
54+
has_been_read? && type.changed_in_place?(old_value, value)
5555
end
5656

5757
def with_value_from_user(value)
@@ -78,6 +78,10 @@ def came_from_user?
7878
false
7979
end
8080

81+
def has_been_read?
82+
defined?(@value)
83+
end
84+
8185
def ==(other)
8286
self.class == other.class &&
8387
name == other.name &&

‎activerecord/lib/active_record/attribute_methods.rb‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,39 @@ def []=(attr_name, value)
369369
write_attribute(attr_name, value)
370370
end
371371

372+
# Returns the name of all database fields which have been read from this
373+
# model. This can be useful in devleopment mode to determine which fields
374+
# need to be selected. For performance critical pages, selecting only the
375+
# required fields can be an easy performance win (assuming you aren't using
376+
# all of the fields on the model).
377+
#
378+
# For example:
379+
#
380+
# class PostsController < ActionController::Base
381+
# after_action :print_accessed_fields, only: :index
382+
#
383+
# def index
384+
# @posts = Post.all
385+
# end
386+
#
387+
# private
388+
#
389+
# def print_accessed_fields
390+
# p @posts.first.accessed_fields
391+
# end
392+
# end
393+
#
394+
# Which allows you to quickly change your code to:
395+
#
396+
# class PostsController < ActionController::Base
397+
# def index
398+
# @posts = Post.select(:id, :title, :author_id, :updated_at)
399+
# end
400+
# end
401+
def accessed_fields
402+
@attributes.accessed
403+
end
404+
372405
protected
373406

374407
def clone_attribute_value(reader_method, attribute_name) # :nodoc:

‎activerecord/lib/active_record/attribute_set.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ def reset(key)
6464
end
6565
end
6666

67+
def accessed
68+
attributes.select { |_, attr| attr.has_been_read? }.keys
69+
end
70+
6771
protected
6872

6973
attr_reader :attributes

‎activerecord/test/cases/attribute_methods_test.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,16 @@ def test_came_from_user
937937
assert model.id_came_from_user?
938938
end
939939

940+
def test_accessed_fields
941+
model = @target.first
942+
943+
assert_equal [], model.accessed_fields
944+
945+
model.title
946+
947+
assert_equal ["title"], model.accessed_fields
948+
end
949+
940950
private
941951

942952
def new_topic_like_ar_class(&block)

‎activerecord/test/cases/attribute_set_test.rb‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,5 +186,16 @@ def attributes_with_uninitialized_key
186186
attributes.freeze
187187
assert_equal({ foo: "1" }, attributes.to_hash)
188188
end
189+
190+
test "#accessed_attributes returns only attributes which have been read" do
191+
builder = AttributeSet::Builder.new(foo: Type::Value.new, bar: Type::Value.new)
192+
attributes = builder.build_from_database(foo: "1", bar: "2")
193+
194+
assert_equal [], attributes.accessed
195+
196+
attributes.fetch_value(:foo)
197+
198+
assert_equal [:foo], attributes.accessed
199+
end
189200
end
190201
end

‎activerecord/test/cases/attribute_test.rb‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,24 @@ def type_cast_from_database(value)
169169
second = Attribute.from_user(:foo, 1, Type::Integer.new)
170170
assert_not_equal first, second
171171
end
172+
173+
test "an attribute has not been read by default" do
174+
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
175+
assert_not attribute.has_been_read?
176+
end
177+
178+
test "an attribute has been read when its value is calculated" do
179+
attribute = Attribute.from_database(:foo, 1, Type::Value.new)
180+
attribute.value
181+
assert attribute.has_been_read?
182+
end
183+
184+
test "an attribute can not be mutated if it has not been read,
185+
and skips expensive calculations" do
186+
type_which_raises_from_all_methods = Object.new
187+
attribute = Attribute.from_database(:foo, "bar", type_which_raises_from_all_methods)
188+
189+
assert_not attribute.changed_in_place_from?("bar")
190+
end
172191
end
173192
end

0 commit comments

Comments
 (0)