Five More Active Record Features You Should Be Using
27 Mar 2016In a previous post, I illustrated a few helpful ActiveRecord
features. The entire API of ActiveRecord
cannot possibly be contained within a single or even a handful of digestible posts; but, here are at least five more pieces of that massive API that some might find useful.
Just like before, the example application used to demonstrate these features will be an imaginary Ruby on Rails application: “booksandreviews.com”:
class Book < ActiveRecord::Base
belongs_to :author
has_many :reviews
end
class Author < ActiveRecord::Base
has_many :books
end
class Review < ActiveRecord::Base
belongs_to :book
end
1. pluck
Introduced in Ruby on Rails 4.0, the pluck
method helps keep memory allocation to a minimum when returning results from ActiveRecord
queries. A great use case for the pluck
method is when a database table backing an ActiveRecord
object has a very large number of columns. In this situation, returning an object’s full dataset can be cause unnecessary memory allocation and potentially expensive deserialization (in the case of custom or JSON serialized columns).
To get a list of book_ids
from the 'fantasy'
genre, the pluck
method is a simple to use:
Book.where(genre: 'fantasy').pluck(:id)
# SELECT "books"."id" FROM "books" WHERE "books"."genre" = 'fantasy'
=> [1, 3, 45, ...]
An important thing to recognize about the pluck
method is its return value. Unlike its cousin, select
, the pluck
method does not return an ActiveRecord
instance.
Multiple column names may be passed to pluck
and the values of these columns will be returned in a nested Array
. The returned Array
will maintain the order of columns to how they were requested:
Book.where(genre: 'fantasy').pluck(:id, :title)
# SELECT "books"."id", "books"."title" FROM "books" WHERE "books"."genre" = 'fantasy'
=> [[1, 'A Title'], [3, 'Another One']]
While possible, it might not always be the best idea to request multiple columns in a single pluck
call. Too many columns can produce an unruly result and nullify the performance gain pluck
provided in the first place.
2. transaction
An important aspect of relational databases is its atomic behaviour. When creating ActiveRecord
objects, maintaining this aspect is possible through the use of the transaction
method.
For instance, if an exception occurs during the update of all of a Book's
Reviews
, a transaction
can help mitigate harmful side-effects:
book = Book.find(1)
book.reviews.each do |review|
review.meaningful_update!
end
If the meaningful_update!
method throws an exception, all reviews before the erroring review will update appropriately and all after will not. If this method has a compounding side-effect (like incrementing a field), iterating through all reviews again would be harmful.
With a transaction
, this problem does not exist:
ActiveRecord::Base.transaction do
book = Book.find(1)
book.reviews.each do |review|
review.meaningful_update!
end
end
After adding the transaction
, this new code will revert all changes contained within the transaction
if an exception is raised. It is equivalent to updating all records in a single query.
3. after_commit
Love them or hate them, callbacks in Ruby on Rails are a viable option to solve many problems. ActiveRecord
callbacks are initiated at different times depending on the operation a model is about to undergo.
A fairly common use case for callbacks in Ruby on Rails revolves around what to do after a model is persisted. A model can be persisted by either save
, create
or update
. Since the concept of “saving” is the common denominator for all these methods, it would be reasonable to use the after_save
to trigger logic that should follow one of these persistence operations.
To add a new Book
to a queue for a Review
after it is saved:
class Book < ActiveRecord::Base
after_save :enqueue_for_review
def enqueue_for_review
ReviewQueue.add(book_id: self.id)
Logger.info("Added #{self.id} to ReviewQueue")
end
end
We can assume that the ReviewQueue
is a key/value storage (Redis or something similar) backed object whose purpose is to place new Books
into a queue for critics to review. The Logger
class simply outputs text to stdout
.
When everything goes well, the callback works beautifully:
Book.create!(
title: 'A New Book',
author_id: 3,
content: 'Blah blah...'
)
#=> Added 4 to ReviewQueue
#=> <Book id: 4, title: 'A New Book' ..>
ReviewQueue.size
#=> 1
However, if this code was wrapped in a transaction
, and that transaction
fails, the Book
will not persist but the element on in the Redis-backed ReviewQueue
remains.
ActiveRecord::Base.transaction do
Book.create!(
title: 'A New Book',
author_id: 3,
content: 'Blah blah...'
)
raise StandardError, 'Something Happened'
end
#=> Added 4 to ReviewQueue
#=> Error 'Something Happened'
ReviewQueue.size
#=> 1
This code has now generated an element on the ReviewQueue
for a non-existent Book
; however, if the after_commit
callback is used, this problem will fade away.
Replacing after_save
with after_commit
:
class Book < ActiveRecord::Base
after_commit :enqueue_for_review
# ...
end
The same code results a much more desirable outcome:
ActiveRecord::Base.transaction do
Book.create!(
title: 'A New Book',
author_id: 3,
content: 'Blah blah...'
)
raise StandardError, 'Something Happened'
end
#=> Error 'Something Happened'
ReviewQueue.size
#=> 0
Awesome, the after_commit
callback is only triggered after the record is persisted to the database, exactly what we wanted. Ruby on Rails provides many more callback hooks besides after_commit
.
4. touch
To update a single timestamp column on an ActiveRecord
object, touch
is a great option. This method can accept the column name which should be updated in addition to an object’s :updated_at
(if present).
A great time to utilize the touch
method is when tracking when a record was last viewed. The good people at “booksandreviews.com” need to be sure their Reviews
are being seen and want to know which Reviews
have not been viewed in a while.
If the Review
table has a last_viewed_at
column which is of type timestamp
, touch
can easily update this column in a controller action:
class ReviewsController < ApplicationController
def show
@review = Review.find(params[:id])
@review.touch(:last_viewed_at)
end
end
A request which calls this controller action will result in the query:
UPDATE "reviews"
SET "updated_at" = '2016-03-28 00:43:43.616367',
"last_viewed_at" = '2016-03-28 00:43:43.616367'
WHERE "reviews"."id" = 1
Unlike setting other attributes, the touch
method invokes an update query right away.
5. changes
ActiveRecord
keeps a sizable amount of information about an object while it undergoes updates. A hash, accessible via the changes
method, acts a central location for these updates. Additionally, ActiveRecord
exposes a number of per-attribute helper methods to give even more visibility.
Each key in the changes
hash maps to a record’s attribute. The value of each key is an Array with two elements: what the attribute value used to be, and what it is now:
review = Review.find(1)
review.book_id = 5
review.changes
# => {"book_id"=>[4, 5]}
a. changed?
A simple boolean method changed?
is available to determine if anything on the object is different since its retrieval.
review = Review.find(1)
review.book_id = 5
review.changed?
# => true
However, if the same value is set on a model, regardless of the value’s object_id
, changed?
returns false
:
book = Book.find(1)
book.title
# => 'A Book'
book.title.object_id
# => 2158279020
book.title = 'A Book'
book.changed?
# => false
book.title.object_id
# => 2158274600
Two “different” strings were set to book.title
but since they resolve to the same characters, the object is not changed?
.
When using PostgreSQL, the changed?
method can save unnecessary begin
and commit
transaction calls for objects that have no changes:
class BooksController < ApplicationController
def update
@book = Book.find(sanitized_params[:id])
@book.assign_attributes(sanitized_params)
@book.save! if @book.changed?
end
end
b. <attribute>_was
Accessing the same changes
hash as the changed?
method, <attribute>_was
returns the value of an attribute before it was reassigned:
review = Review.find(1)
review.status = 'Approved'
review.status_was
# => 'Pending'
If a Review's
status moving from "Pending"
to "Approved"
should remove it from an approval queue, the status_was
method can be utilized in a callback:
class Review
before_save :maybe_remove_from_review_queue
def maybe_remove_from_review_queue
if status == 'Approved' &&
status_was == 'Pending'
ReviewQueue.remove(id)
end
end
end
To verify the result, we can check that the ReviewQueue
decrements its internal count of reviews pending review:
ReviewQueue.size
#=> 1
review = Review.find(1)
review.status
# => 'Pending'
review.status = 'Approved'
review.save!
# => true
ReviewQueue.size
#=> 0
Note: Additional considerations might need to be made since the save
is not guaranteed to persist like described above in the after_commit
example.