Skip to content

Commit 591ede4

Browse files
committed
Five Active Record Features You Should Be Using
1 parent d89c1e4 commit 591ede4

File tree

1 file changed

+125
-0
lines changed

1 file changed

+125
-0
lines changed

rails/five-active-record-features.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Five Active Record Features You Should Be Using
2+
### 1. Nested Queries
3+
When doing database queries, the fewer the better. Since Active Record is responsible of crafting these queries, it is important to make sure it has all the help it needs. For simple queries this is rarely an issue, but more complex requirements can lead to sub-optimal results.
4+
5+
One day, Tim from sales comes rampaging through the office convinced that there must be a bug in the system. A recent sale for "booksandreviews.com" did not go well and he wants answers. Tim wants an analysis run. He wants all reviews published today, which are for books published in 2015.
6+
7+
Without too much thought, this approach seems reasonable:
8+
```ruby
9+
book_ids = Book.where(publish_year: '2015').map(&:id)
10+
# => SELECT "books".* FROM "books" WHERE (publish_year = '2015')
11+
12+
reviews = Review.where(publish_date: '2015-11-15',
13+
book_ids: book_ids).to_a
14+
# => SELECT "reviews".* FROM "reviews" WHERE "reviews"."publish_date" = '2015-11-15' AND "reviews"."book_ids" IN (1, 2, 3)
15+
```
16+
17+
This will load the desired books, extract their id's, and pass that result to the Review query. Not only does this generate two queries, it also wastes memory by creating an array of Book objects to map over and then another array of book_ids. With a large enough list of books, this could cause some serious problems.
18+
19+
Active Record's where method returns an instance of ActiveRecord::Relation. These relations can be passed to other methods to aid in query construction. With the same request, we can save the map and array creation:
20+
```ruby
21+
books = Book.where(publish_year: '2015')
22+
# => ActiveRecord::Relation
23+
24+
reviews = Review.where(publish_date: '2015-11-15', book: books).to_a
25+
# SELECT "reviews".* FROM "reviews" WHERE "reviews"."publish_date" = '2015-11-15' AND "reviews"."book_id" IN (SELECT "books"."id" FROM "books" WHERE "books"."publish_year" = '2015')
26+
```
27+
This still executes two SELECT statements but it nests them to let the database take care of memory allocation and optimization. The book_ids array is replaced with the books relation and is passed to the Review query.
28+
29+
Note: This can reduced to a single query with .joins, but for now we can assume that a nested query is desired.
30+
31+
### 2. DRY Scopes
32+
Still fuming, Tim demands more information. Now he wants to know the list of all Books published in 2015 which have at least one approved Review. Since Reviews are subjective, they need to be approved in order to maintain the quality that "booksandreviews.com" is known for.
33+
34+
Luckily, a scope has been written on the Review class to accomplish this.
35+
```ruby
36+
class Review < ActiveRecord::Base
37+
belongs_to :book
38+
scope :approved, ->{ where(approved: true) }
39+
end
40+
```
41+
However, it is Books that we need to return, not Reviews. Repeating the scope definition, a join query can be used for this analysis:
42+
```ruby
43+
books = Book.where(publish_year: '2015')
44+
.includes(:reviews)
45+
.references(:reviews)
46+
.where('reviews.approved = ?', true )
47+
.to_a
48+
# => SELECT #long books and reviews column select# FROM "books" LEFT OUTER JOIN "reviews" ON "reviews"."book_id" = "books"."id" WHERE "books"."publish_year" = '2015' AND (reviews.approved = 't')
49+
```
50+
Books are returned at the cost of duplicating the approved scope. That means that the scope in Review changes, this code will not benefit from that change. The .includes and .references methods are used to ensure that we only return one Book (in the case of many Reviews belonging to the same Book).
51+
52+
The Don't Repeat Yourself (DRY) principle was created for this exact reason. When identical code is not shared and instead repeated, changes to one version can have dangerous consequences on the other.
53+
54+
The good news is that Active Record provides precisely the medicine for this ailment: .merge.
55+
56+
With .merge, an existing scope can be used in another Active Record query.
57+
```ruby
58+
books = Book.where(publish_year: '2015')
59+
.includes(:reviews)
60+
.references(:reviews)
61+
.merge(Review.approved)
62+
.to_a
63+
# => SELECT #long books and reviews column select# FROM "books" LEFT OUTER JOIN "reviews" ON "reviews"."book_id" = "books"."id" WHERE "books"."publish_year" = '2015' AND (reviews.approved = 't')
64+
```
65+
Great! Now the results are the exact same and the code is DRY.
66+
67+
### 3. where.not
68+
Typical insatiable Tim is back with yet another request to add to the brand new "totally not vanity metrics dashboard". Now, he wants to know all the books not published in 2012.
69+
70+
Without even asking why such a silly request is necessary, some more code can be cranked out:
71+
```ruby
72+
books = Book.where('publish_year != 2012').to_a
73+
# => SELECT "books".* FROM "books" WHERE (publish_year != '2012')
74+
```
75+
Like before, this code works but could be better. There is some raw SQL in there that the next developer might not understand well enough to manipulate. Whatever the reason, it is best to rely on abstraction instead of explicit SQL.
76+
77+
To help solve this dilemma, the .not modifier has been introduced in Active Record 4.0.
78+
```ruby
79+
books = Book.where.not(publish_year: 2012).to_a
80+
# => SELECT "books".* FROM "books" WHERE (publish_year != '2012')
81+
```
82+
The result is the same but look how much nicer that is. Not only is the raw SQL gone, the code is more positive too.
83+
84+
### 4. first and take
85+
Since "booksandreviews.com" has been around since 2012, chances are it upgraded from Ruby on Rails 3.0 to 4.0. One notable change from Active Record 3 to 4 is the behaviour of .first.
86+
87+
In Ruby on Rails 4.0+, the .first method returns the first row after the table has been ordered by its id.
88+
```ruby
89+
Author.where(first_name: 'Bill').first
90+
# => SELECT "authors".* FROM "authors" WHERE "authors"."first_name" = "Bill" ORDER BY "authors"."id" ASC LIMIT 1
91+
```
92+
This will work fine for every table that has an id column. However, if a table does not need an id column, this method causes a problem.
93+
94+
Despite each Author having an id, complex joins might cause an issue with an implicit ORDER BY on queries.
95+
96+
To alleviate that problem, the take method can be used instead of first:
97+
```ruby
98+
Author.where(first_name: 'Bill').take
99+
# => SELECT "authors".* FROM "authors" WHERE "authors"."first_name" = "Bill" LIMIT 1
100+
```
101+
This behaves in a much more explicit way, returning the same information without a default ordering.
102+
103+
### 5. .unscoped
104+
During the development life of "booksandreviews.com", countless modules have been built and gems included. Amidst this chaos, someone must have typed gem install hairball and horribly altered the Author class. This has led to the new guy Mike's complaint that: "Authors are missing data".
105+
106+
Mike knows that authors have a first_name but for some reason it is not being returned:
107+
```ruby
108+
authors = Author.where(last_name: 'Smith').take(5)
109+
authors.map(&:first_name)
110+
# => [nil, nil, nil, nil, nil]
111+
```
112+
What Mike does not know is that one of those hairball gems added a default scope to all Active Record objects that begin with the letter "A". However impossible this bug is, it exists and it is ruining Mike's day.
113+
114+
What Mike needs is the .unscoped method. This method removes all existing scopes on an Active Record relation.
115+
```ruby
116+
authors = Author.unscoped.where(last_name: 'Smith').take(5)
117+
authors.map(&:first_name)
118+
# => ['Frank', 'Frank', 'Jim', 'Frank', 'Frank']
119+
```
120+
(Is anyone else concerned that there are four Frank Smith authors?)
121+
122+
With the .unscoped method, all harmful default scopes are removed and the Franks are free.
123+
124+
Queries on Queries
125+
With these five techniques (and probably a lot more), naiive Active Record queries can stay DRY and intuitive. The exhaustive list of what Active Record can provide can be found at Rails Guides.

0 commit comments

Comments
 (0)