Skip to content

Commit 89693e5

Browse files
authored
Merge pull request rails#50482 from p8/activerecord/explain-proxy
Add `explain` support for methods like `last`, `pluck` and `count`
2 parents 64c9360 + 1f83af3 commit 89693e5

File tree

8 files changed

+188
-17
lines changed

8 files changed

+188
-17
lines changed

activerecord/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
* Add `explain` support for `last`, `pluck` and `count`
2+
3+
Let `explain` return a proxy that delegates these methods:
4+
5+
```ruby
6+
User.all.explain.count
7+
# EXPLAIN SELECT COUNT(*) FROM `users`
8+
# ...
9+
10+
User.all.explain.maximum(:id)
11+
# EXPLAIN SELECT MAX(`users`.`id`) FROM `users`
12+
# ...
13+
```
14+
15+
*Petrik de Heus*
16+
117
* Validate using `:on` option when using `validates_associated`
218

319
Fixes an issue where `validates_associated` `:on` option wasn't respected

activerecord/lib/active_record/relation.rb

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,54 @@
33
module ActiveRecord
44
# = Active Record \Relation
55
class Relation
6+
class ExplainProxy # :nodoc:
7+
def initialize(relation, options)
8+
@relation = relation
9+
@options = options
10+
end
11+
12+
def inspect
13+
exec_explain { @relation.send(:exec_queries) }
14+
end
15+
16+
def average(column_name)
17+
exec_explain { @relation.average(column_name) }
18+
end
19+
20+
def count(column_name = nil)
21+
exec_explain { @relation.count(column_name) }
22+
end
23+
24+
def first(limit = nil)
25+
exec_explain { @relation.first(limit) }
26+
end
27+
28+
def last(limit = nil)
29+
exec_explain { @relation.last(limit) }
30+
end
31+
32+
def maximum(column_name)
33+
exec_explain { @relation.maximum(column_name) }
34+
end
35+
36+
def minimum(column_name)
37+
exec_explain { @relation.minimum(column_name) }
38+
end
39+
40+
def pluck(*column_names)
41+
exec_explain { @relation.pluck(*column_names) }
42+
end
43+
44+
def sum(identity_or_column = nil)
45+
exec_explain { @relation.sum(identity_or_column) }
46+
end
47+
48+
private
49+
def exec_explain(&block)
50+
@relation.exec_explain(@relation.collecting_queries_for_explain { block.call }, @options)
51+
end
52+
end
53+
654
MULTI_VALUE_METHODS = [:includes, :eager_load, :preload, :select, :group,
755
:order, :joins, :left_outer_joins, :references,
856
:extending, :unscope, :optimizer_hints, :annotate,
@@ -245,13 +293,30 @@ def find_or_initialize_by(attributes, &block)
245293
# returns the result as a string. The string is formatted imitating the
246294
# ones printed by the database shell.
247295
#
296+
# User.all.explain
297+
# # EXPLAIN SELECT `cars`.* FROM `cars`
298+
# # ...
299+
#
248300
# Note that this method actually runs the queries, since the results of some
249301
# are needed by the next ones when eager loading is going on.
250302
#
303+
# To run EXPLAIN on queries created by `first`, `pluck` and `count`, call
304+
# these methods on `explain`:
305+
#
306+
# User.all.explain.count
307+
# # EXPLAIN SELECT COUNT(*) FROM `users`
308+
# # ...
309+
#
310+
# The column name can be passed if required:
311+
#
312+
# User.all.explain.maximum(:id)
313+
# # EXPLAIN SELECT MAX(`users`.`id`) FROM `users`
314+
# # ...
315+
#
251316
# Please see further details in the
252317
# {Active Record Query Interface guide}[https://guides.rubyonrails.org/active_record_querying.html#running-explain].
253318
def explain(*options)
254-
exec_explain(collecting_queries_for_explain { exec_queries }, options)
319+
ExplainProxy.new(self, options)
255320
end
256321

257322
# Converts relation objects to Array.

activerecord/test/cases/adapters/abstract_mysql_adapter/mysql_explain_test.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,31 @@ class MySQLExplainTest < ActiveRecord::AbstractMysqlTestCase
88
fixtures :authors, :author_addresses
99

1010
def test_explain_for_one_query
11-
explain = Author.where(id: 1).explain
11+
explain = Author.where(id: 1).explain.inspect
1212
assert_match %(EXPLAIN SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain
1313
assert_match %r(authors |.* const), explain
1414
end
1515

1616
def test_explain_with_eager_loading
17-
explain = Author.where(id: 1).includes(:posts).explain
17+
explain = Author.where(id: 1).includes(:posts).explain.inspect
1818
assert_match %(EXPLAIN SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain
1919
assert_match %r(authors |.* const), explain
2020
assert_match %(EXPLAIN SELECT `posts`.* FROM `posts` WHERE `posts`.`author_id` = 1), explain
2121
assert_match %r(posts |.* ALL), explain
2222
end
2323

2424
def test_explain_with_options_as_symbol
25-
explain = Author.where(id: 1).explain(explain_option)
25+
explain = Author.where(id: 1).explain(explain_option).inspect
2626
assert_match %(#{expected_analyze_clause} SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain
2727
end
2828

2929
def test_explain_with_options_as_strings
30-
explain = Author.where(id: 1).explain(explain_option.to_s.upcase)
30+
explain = Author.where(id: 1).explain(explain_option.to_s.upcase).inspect
3131
assert_match %(#{expected_analyze_clause} SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain
3232
end
3333

3434
def test_explain_options_with_eager_loading
35-
explain = Author.where(id: 1).includes(:posts).explain(explain_option)
35+
explain = Author.where(id: 1).includes(:posts).explain(explain_option).inspect
3636
assert_match %(#{expected_analyze_clause} SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain
3737
assert_match %(#{expected_analyze_clause} SELECT `posts`.* FROM `posts` WHERE `posts`.`author_id` = 1), explain
3838
end

activerecord/test/cases/adapters/abstract_mysql_adapter/optimizer_hints_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def test_optimizer_hints
1111
assert_queries_match(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do
1212
posts = Post.optimizer_hints("NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id)")
1313
posts = posts.select(:id).where(author_id: [0, 1])
14-
assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |"
14+
assert_includes posts.explain.inspect, "| index | index_posts_on_author_id | index_posts_on_author_id |"
1515
end
1616
end
1717

@@ -27,7 +27,7 @@ def test_optimizer_hints_is_sanitized
2727
assert_queries_match(%r{\ASELECT /\*\+ NO_RANGE_OPTIMIZATION\(posts index_posts_on_author_id\) \*/}) do
2828
posts = Post.optimizer_hints("/*+ NO_RANGE_OPTIMIZATION(posts index_posts_on_author_id) */")
2929
posts = posts.select(:id).where(author_id: [0, 1])
30-
assert_includes posts.explain, "| index | index_posts_on_author_id | index_posts_on_author_id |"
30+
assert_includes posts.explain.inspect, "| index | index_posts_on_author_id | index_posts_on_author_id |"
3131
end
3232

3333
assert_queries_match(%r{\ASELECT /\*\+ \*\* // `posts`\.\*, // \*\* \*/}) do

activerecord/test/cases/adapters/postgresql/explain_test.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,32 @@ class PostgreSQLExplainTest < ActiveRecord::PostgreSQLTestCase
88
fixtures :authors, :author_addresses
99

1010
def test_explain_for_one_query
11-
explain = Author.where(id: 1).explain
11+
explain = Author.where(id: 1).explain.inspect
1212
assert_match %r(EXPLAIN SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain
1313
assert_match %(QUERY PLAN), explain
1414
end
1515

1616
def test_explain_with_eager_loading
17-
explain = Author.where(id: 1).includes(:posts).explain
17+
explain = Author.where(id: 1).includes(:posts).explain.inspect
1818
assert_match %(QUERY PLAN), explain
1919
assert_match %r(EXPLAIN SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain
2020
assert_match %r(EXPLAIN SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\$1 \[\["author_id", 1\]\]|1)), explain
2121
end
2222

2323
def test_explain_with_options_as_symbols
24-
explain = Author.where(id: 1).explain(:analyze, :buffers)
24+
explain = Author.where(id: 1).explain(:analyze, :buffers).inspect
2525
assert_match %r(EXPLAIN \(ANALYZE, BUFFERS\) SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain
2626
assert_match %(QUERY PLAN), explain
2727
end
2828

2929
def test_explain_with_options_as_strings
30-
explain = Author.where(id: 1).explain("VERBOSE", "ANALYZE", "FORMAT JSON")
30+
explain = Author.where(id: 1).explain("VERBOSE", "ANALYZE", "FORMAT JSON").inspect
3131
assert_match %r(EXPLAIN \(VERBOSE, ANALYZE, FORMAT JSON\) SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain
3232
assert_match %(QUERY PLAN), explain
3333
end
3434

3535
def test_explain_options_with_eager_loading
36-
explain = Author.where(id: 1).includes(:posts).explain(:analyze)
36+
explain = Author.where(id: 1).includes(:posts).explain(:analyze).inspect
3737
assert_match %(QUERY PLAN), explain
3838
assert_match %r(EXPLAIN \(ANALYZE\) SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain
3939
assert_match %r(EXPLAIN \(ANALYZE\) SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\$1 \[\["author_id", 1\]\]|1)), explain

activerecord/test/cases/adapters/sqlite3/explain_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ class SQLite3ExplainTest < ActiveRecord::SQLite3TestCase
88
fixtures :authors, :author_addresses
99

1010
def test_explain_for_one_query
11-
explain = Author.where(id: 1).explain
11+
explain = Author.where(id: 1).explain.inspect
1212
assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain
1313
assert_match(/(SEARCH )?(TABLE )?authors USING (INTEGER )?PRIMARY KEY/, explain)
1414
end
1515

1616
def test_explain_with_eager_loading
17-
explain = Author.where(id: 1).includes(:posts).explain
17+
explain = Author.where(id: 1).includes(:posts).explain.inspect
1818
assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain
1919
assert_match(/(SEARCH )?(TABLE )?authors USING (INTEGER )?PRIMARY KEY/, explain)
2020
assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\? \[\["author_id", 1\]\]|1)), explain

activerecord/test/cases/base_prevent_writes_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class BasePreventWritesTest < ActiveRecord::TestCase
5151
Bird.create!(name: "Bluejay")
5252

5353
ActiveRecord::Base.while_preventing_writes do
54-
assert_queries_count(2) { Bird.where(name: "Bluejay").explain }
54+
assert_queries_count(2) { Bird.where(name: "Bluejay").explain.inspect }
5555
end
5656
end
5757

activerecord/test/cases/explain_test.rb

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def connection
1616
end
1717

1818
def test_relation_explain
19-
message = Car.where(name: "honda").explain
19+
message = Car.where(name: "honda").explain.inspect
2020
assert_match(/^EXPLAIN/, message)
2121
end
2222

@@ -35,6 +35,96 @@ def test_collecting_queries_for_explain
3535
end
3636
end
3737

38+
def test_relation_explain_with_average
39+
expected_query = capture_sql {
40+
Car.average(:id)
41+
}.first
42+
message = Car.all.explain.average(:id)
43+
assert_match(/^EXPLAIN/, message)
44+
assert_match(expected_query, message)
45+
end
46+
47+
def test_relation_explain_with_count
48+
expected_query = capture_sql {
49+
Car.count
50+
}.first
51+
message = Car.all.explain.count
52+
assert_match(/^EXPLAIN/, message)
53+
assert_match(expected_query, message)
54+
end
55+
56+
def test_relation_explain_with_count_and_argument
57+
expected_query = capture_sql {
58+
Car.count(:id)
59+
}.first
60+
message = Car.all.explain.count(:id)
61+
assert_match(/^EXPLAIN/, message)
62+
assert_match(expected_query, message)
63+
end
64+
65+
def test_relation_explain_with_minimum
66+
expected_query = capture_sql {
67+
Car.minimum(:id)
68+
}.first
69+
message = Car.all.explain.minimum(:id)
70+
assert_match(/^EXPLAIN/, message)
71+
assert_match(expected_query, message)
72+
end
73+
74+
def test_relation_explain_with_maximum
75+
expected_query = capture_sql {
76+
Car.maximum(:id)
77+
}.first
78+
message = Car.all.explain.maximum(:id)
79+
assert_match(/^EXPLAIN/, message)
80+
assert_match(expected_query, message)
81+
end
82+
83+
def test_relation_explain_with_sum
84+
expected_query = capture_sql {
85+
Car.sum(:id)
86+
}.first
87+
message = Car.all.explain.sum(:id)
88+
assert_match(/^EXPLAIN/, message)
89+
assert_match(expected_query, message)
90+
end
91+
92+
def test_relation_explain_with_first
93+
expected_query = capture_sql {
94+
Car.all.first
95+
}.first
96+
message = Car.all.explain.first
97+
assert_match(/^EXPLAIN/, message)
98+
assert_match(expected_query, message)
99+
end
100+
101+
def test_relation_explain_with_last
102+
expected_query = capture_sql {
103+
Car.all.last
104+
}.first
105+
message = Car.all.explain.last
106+
assert_match(/^EXPLAIN/, message)
107+
assert_match(expected_query, message)
108+
end
109+
110+
def test_relation_explain_with_pluck
111+
expected_query = capture_sql {
112+
Car.all.pluck
113+
}.first
114+
message = Car.all.explain.pluck
115+
assert_match(/^EXPLAIN/, message)
116+
assert_match(expected_query, message)
117+
end
118+
119+
def test_relation_explain_with_pluck_with_args
120+
expected_query = capture_sql {
121+
Car.all.pluck(:id, :name)
122+
}.first
123+
message = Car.all.explain.pluck(:id, :name)
124+
assert_match(/^EXPLAIN/, message)
125+
assert_match(expected_query, message)
126+
end
127+
38128
def test_exec_explain_with_no_binds
39129
sqls = %w(foo bar)
40130
binds = [[], []]

0 commit comments

Comments
 (0)