diff options
-rw-r--r-- | actionpack/actionpack.gemspec | 2 | ||||
-rw-r--r-- | activerecord/CHANGELOG.md | 2 | ||||
-rw-r--r-- | activerecord/lib/active_record/base.rb | 3 | ||||
-rw-r--r-- | activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb | 75 | ||||
-rw-r--r-- | activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb | 44 | ||||
-rw-r--r-- | activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb | 19 | ||||
-rw-r--r-- | activerecord/lib/active_record/relation.rb | 16 | ||||
-rw-r--r-- | activerecord/test/cases/adapters/mysql2/explain_test.rb | 23 | ||||
-rw-r--r-- | activerecord/test/cases/adapters/postgresql/explain_test.rb | 25 | ||||
-rw-r--r-- | activerecord/test/cases/adapters/sqlite3/explain_test.rb | 23 | ||||
-rw-r--r-- | activerecord/test/cases/attribute_methods_test.rb | 18 | ||||
-rw-r--r-- | activesupport/CHANGELOG.md | 2 | ||||
-rw-r--r-- | activesupport/lib/active_support/notifications.rb | 41 | ||||
-rw-r--r-- | activesupport/test/notifications_test.rb | 20 | ||||
-rw-r--r-- | railties/guides/source/active_record_querying.textile | 65 | ||||
-rw-r--r-- | railties/lib/rails/generators/app_base.rb | 2 |
16 files changed, 377 insertions, 3 deletions
diff --git a/actionpack/actionpack.gemspec b/actionpack/actionpack.gemspec index c261fc8a8f..a446531a6b 100644 --- a/actionpack/actionpack.gemspec +++ b/actionpack/actionpack.gemspec @@ -24,7 +24,7 @@ Gem::Specification.new do |s| s.add_dependency('rack', '~> 1.3.5') s.add_dependency('rack-test', '~> 0.6.1') s.add_dependency('journey', '~> 1.0.0') - s.add_dependency('sprockets', '~> 2.0.3') + s.add_dependency('sprockets', '~> 2.1.0.beta') s.add_dependency('erubis', '~> 2.7.0') s.add_development_dependency('tzinfo', '~> 0.3.29') diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 6b1cf4c841..a79f4df570 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,5 +1,7 @@ ## Rails 3.2.0 (unreleased) ## +* Implemented ActiveRecord::Relation#explain. *fxn* + * Add ActiveRecord::Relation#uniq for generating unique queries. Before: diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 455b299270..feeebb7131 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -1771,7 +1771,8 @@ MSG # Returns true if the specified +attribute+ has been set by the user or by a database load and is neither # nil nor empty? (the latter only applies to objects that respond to empty?, most notably Strings). def attribute_present?(attribute) - !_read_attribute(attribute).blank? + value = _read_attribute(attribute) + !value.nil? || (value.respond_to?(:empty?) && !value.empty?) end # Returns the column object for the named attribute. diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index 306b185c5e..1146323147 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -228,6 +228,80 @@ module ActiveRecord # DATABASE STATEMENTS ====================================== + def explain(arel) + sql = "EXPLAIN #{to_sql(arel)}" + start = Time.now + result = exec_query(sql, 'EXPLAIN') + elapsed = Time.now - start + + ExplainPrettyPrinter.new.pp(result, elapsed) + end + + class ExplainPrettyPrinter # :nodoc: + # Pretty prints the result of a EXPLAIN in a way that resembles the output of the + # MySQL shell: + # + # +----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+ + # | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | + # +----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+ + # | 1 | SIMPLE | users | const | PRIMARY | PRIMARY | 4 | const | 1 | | + # | 1 | SIMPLE | posts | ALL | NULL | NULL | NULL | NULL | 1 | Using where | + # +----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+ + # 2 rows in set (0.00 sec) + # + # This is an exercise in Ruby hyperrealism :). + def pp(result, elapsed) + widths = compute_column_widths(result) + separator = build_separator(widths) + + pp = [] + + pp << separator + pp << build_cells(result.columns, widths) + pp << separator + + result.rows.each do |row| + pp << build_cells(row, widths) + end + + pp << separator + pp << build_footer(result.rows.length, elapsed) + + pp.join("\n") + "\n" + end + + private + + def compute_column_widths(result) + [].tap do |widths| + result.columns.each_with_index do |column, i| + cells_in_column = [column] + result.rows.map {|r| r[i].nil? ? 'NULL' : r[i].to_s} + widths << cells_in_column.map(&:length).max + end + end + end + + def build_separator(widths) + # Each cell has one char of padding at both sides, that's why we add 2. + '+' + widths.map {|w| '-' * (w + 2)}.join('+') + end + + def build_cells(items, widths) + cells = [] + items.each_with_index do |item, i| + item = 'NULL' if item.nil? + justifier = item.is_a?(Numeric) ? 'rjust' : 'ljust' + cells << item.to_s.send(justifier, widths[i]) + end + '| ' + cells.join(' | ') + ' |' + end + + def build_footer(nrows, elapsed) + rows_label = nrows == 1 ? 'row' : 'rows' + "#{nrows} #{rows_label} in set (%.2f sec)" % elapsed + end + end + # Executes the SQL statement in the context of this connection. def execute(sql, name = nil) if name == :skip_logging @@ -290,6 +364,7 @@ module ActiveRecord # these, we must use a subquery. However, MySQL is too stupid to create a # temporary table for this automatically, so we have to give it some prompting # in the form of a subsubquery. Ugh! + def join_to_update(update, select) #:nodoc: if select.limit || select.offset || select.orders.any? subsubselect = select.clone diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 15e329a1c8..44c2fa439c 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -517,6 +517,48 @@ module ActiveRecord # DATABASE STATEMENTS ====================================== + def explain(arel) + sql = "EXPLAIN #{to_sql(arel)}" + ExplainPrettyPrinter.new.pp(exec_query(sql)) + end + + class ExplainPrettyPrinter # :nodoc: + # Pretty prints the result of a EXPLAIN in a way that resembles the output of the + # PostgreSQL shell: + # + # QUERY PLAN + # ------------------------------------------------------------------------------ + # Nested Loop Left Join (cost=0.00..37.24 rows=8 width=0) + # Join Filter: (posts.user_id = users.id) + # -> Index Scan using users_pkey on users (cost=0.00..8.27 rows=1 width=4) + # Index Cond: (id = 1) + # -> Seq Scan on posts (cost=0.00..28.88 rows=8 width=4) + # Filter: (posts.user_id = 1) + # (6 rows) + # + def pp(result) + header = result.columns.first + lines = result.rows.map(&:first) + + # We add 2 because there's one char of padding at both sides, note + # the extra hyphens in the example above. + width = [header, *lines].map(&:length).max + 2 + + pp = [] + + pp << header.center(width).rstrip + pp << '-' * width + + pp += lines.map {|line| " #{line}"} + + nrows = result.rows.length + rows_label = nrows == 1 ? 'row' : 'rows' + pp << "(#{nrows} #{rows_label})" + + pp.join("\n") + "\n" + end + end + # Executes a SELECT query and returns an array of rows. Each row is an # array of field values. def select_rows(sql, name = nil) @@ -836,7 +878,7 @@ module ActiveRecord # Returns the active schema search path. def schema_search_path - @schema_search_path ||= query('SHOW search_path')[0][0] + @schema_search_path ||= query('SHOW search_path', 'SCHEMA')[0][0] end # Returns the current client message level. diff --git a/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb index caecbc9b3a..35df0a1542 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite_adapter.rb @@ -222,6 +222,25 @@ module ActiveRecord # DATABASE STATEMENTS ====================================== + def explain(arel) + sql = "EXPLAIN QUERY PLAN #{to_sql(arel)}" + ExplainPrettyPrinter.new.pp(exec_query(sql, 'EXPLAIN')) + end + + class ExplainPrettyPrinter + # Pretty prints the result of a EXPLAIN QUERY PLAN in a way that resembles + # the output of the SQLite shell: + # + # 0|0|0|SEARCH TABLE users USING INTEGER PRIMARY KEY (rowid=?) (~1 rows) + # 0|1|1|SCAN TABLE posts (~100000 rows) + # + def pp(result) # :nodoc: + result.rows.map do |row| + row.join('|') + end.join("\n") + "\n" + end + end + def exec_query(sql, name = nil, binds = []) log(sql, name, binds) do diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 3baf9b3f49..f0891440a6 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -143,6 +143,22 @@ module ActiveRecord super end + def explain + queries = [] + callback = lambda do |*args| + payload = args.last + queries << payload[:sql] unless payload[:exception] || %w(SCHEMA EXPLAIN).include?(payload[:name]) + end + + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + to_a + end + + queries.map do |sql| + @klass.connection.explain(sql) + end.join + end + def to_a return @records if loaded? diff --git a/activerecord/test/cases/adapters/mysql2/explain_test.rb b/activerecord/test/cases/adapters/mysql2/explain_test.rb new file mode 100644 index 0000000000..8ea777b72b --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/explain_test.rb @@ -0,0 +1,23 @@ +require "cases/helper" +require 'models/developer' + +module ActiveRecord + module ConnectionAdapters + class Mysql2Adapter + class ExplainTest < ActiveRecord::TestCase + fixtures :developers + + def test_explain_for_one_query + explain = Developer.where(:id => 1).explain + assert_match %(developers | const), explain + end + + def test_explain_with_eager_loading + explain = Developer.where(:id => 1).includes(:audit_logs).explain + assert_match %(developers | const), explain + assert_match %(audit_logs | ALL), explain + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/explain_test.rb b/activerecord/test/cases/adapters/postgresql/explain_test.rb new file mode 100644 index 0000000000..0d599ed37f --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/explain_test.rb @@ -0,0 +1,25 @@ +require "cases/helper" +require 'models/developer' + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapter + class ExplainTest < ActiveRecord::TestCase + fixtures :developers + + def test_explain_for_one_query + explain = Developer.where(:id => 1).explain + assert_match %(QUERY PLAN), explain + assert_match %(Index Scan using developers_pkey on developers), explain + end + + def test_explain_with_eager_loading + explain = Developer.where(:id => 1).includes(:audit_logs).explain + assert_match %(QUERY PLAN), explain + assert_match %(Index Scan using developers_pkey on developers), explain + assert_match %(Seq Scan on audit_logs), explain + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/explain_test.rb b/activerecord/test/cases/adapters/sqlite3/explain_test.rb new file mode 100644 index 0000000000..97be9f14e9 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/explain_test.rb @@ -0,0 +1,23 @@ +require "cases/helper" +require 'models/developer' + +module ActiveRecord + module ConnectionAdapters + class SQLite3Adapter + class ExplainTest < ActiveRecord::TestCase + fixtures :developers + + def test_explain_for_one_query + explain = Developer.where(:id => 1).explain + assert_match %(SEARCH TABLE developers USING INTEGER PRIMARY KEY), explain + end + + def test_explain_with_eager_loading + explain = Developer.where(:id => 1).includes(:audit_logs).explain + assert_match %(SEARCH TABLE developers USING INTEGER PRIMARY KEY), explain + assert_match %(SCAN TABLE audit_logs), explain + end + end + end + end +end diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index b1b41fed0d..9300c57819 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -35,6 +35,24 @@ class AttributeMethodsTest < ActiveRecord::TestCase assert !t.attribute_present?("content") end + def test_attribute_present_with_booleans + b1 = Boolean.new + b1.value = false + assert b1.attribute_present?(:value) + + b2 = Boolean.new + b2.value = true + assert b2.attribute_present?(:value) + + b3 = Boolean.new + assert !b3.attribute_present?(:value) + + b4 = Boolean.new + b4.value = false + b4.save! + assert Boolean.find(b4.id).attribute_present?(:value) + end + def test_attribute_keys_on_new_instance t = Topic.new assert_equal nil, t.title, "The topics table has a title column, so it should be nil" diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index ad3828d5e8..e03bfdee92 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,5 +1,7 @@ ## Rails 3.2.0 (unreleased) ## +* ActiveSupport::Notifications.subscribed provides subscriptions to events while a block runs. *fxn* + * Module#qualified_const_(defined?|get|set) are analogous to the corresponding methods in the standard API, but accept qualified constant names. *fxn* diff --git a/activesupport/lib/active_support/notifications.rb b/activesupport/lib/active_support/notifications.rb index 89afe7b982..f549d2fff3 100644 --- a/activesupport/lib/active_support/notifications.rb +++ b/activesupport/lib/active_support/notifications.rb @@ -63,6 +63,40 @@ module ActiveSupport # and even pass no argument to +subscribe+, in which case you are subscribing # to all events. # + # == Temporary Subscriptions + # + # Sometimes you do not want to subscribe to an event for the entire life of + # the application. There are two ways to unsubscribe. + # + # === Subscribe While a Block Runs + # + # You can subscribe to some event temporarily while some block runs. For + # example, in + # + # callback = lambda {|*args| ... } + # ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + # ... + # end + # + # the callback will be called for all "sql.active_record" events instrumented + # during the execution of the block. The callback is unsubscribed automatically + # after that. + # + # === Manual Unsubscription + # + # The +subscribe+ method returns a subscriber object: + # + # subscriber = ActiveSupport::Notifications.subscribe("render") do |*args| + # ... + # end + # + # To prevent that block from being called anymore, just unsubscribe passing + # that reference: + # + # ActiveSupport::Notifications.unsubscribe(subscriber) + # + # == Default Queue + # # Notifications ships with a queue implementation that consumes and publish events # to log subscribers in a thread. You can use any queue implementation you want. # @@ -94,6 +128,13 @@ module ActiveSupport end end + def subscribed(callback, *args, &block) + subscriber = subscribe(*args, &callback) + yield + ensure + unsubscribe(subscriber) + end + def unsubscribe(args) notifier.unsubscribe(args) @instrumenters.clear diff --git a/activesupport/test/notifications_test.rb b/activesupport/test/notifications_test.rb index 884ee61547..fc9fa90d07 100644 --- a/activesupport/test/notifications_test.rb +++ b/activesupport/test/notifications_test.rb @@ -24,6 +24,26 @@ module Notifications end end + class SubscribedTest < TestCase + def test_subscribed + name = "foo" + name2 = name * 2 + expected = [name, name] + + events = [] + callback = lambda {|*_| events << _.first} + ActiveSupport::Notifications.subscribed(callback, name) do + ActiveSupport::Notifications.instrument(name) + ActiveSupport::Notifications.instrument(name2) + ActiveSupport::Notifications.instrument(name) + end + assert_equal expected, events + + ActiveSupport::Notifications.instrument(name) + assert_equal expected, events + end + end + class UnsubscribeTest < TestCase def test_unsubscribing_removes_a_subscription @notifier.publish :foo diff --git a/railties/guides/source/active_record_querying.textile b/railties/guides/source/active_record_querying.textile index 0ad2644095..a132d85ef9 100644 --- a/railties/guides/source/active_record_querying.textile +++ b/railties/guides/source/active_record_querying.textile @@ -8,6 +8,7 @@ This guide covers different ways to retrieve data from the database using Active * Use dynamic finders methods * Check for the existence of particular records * Perform various calculations on Active Record models +* Run EXPLAIN on relations endprologue. @@ -1274,3 +1275,67 @@ Client.sum("orders_count") </ruby> For options, please see the parent section, "Calculations":#calculations. + +h3. Running EXPLAIN + +You can run EXPLAIN on the queries triggered by relations. For example, + +<ruby> +User.where(:id => 1).joins(:posts).explain +</ruby> + +may yield + +<plain> ++----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+ +| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ++----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+ +| 1 | SIMPLE | users | const | PRIMARY | PRIMARY | 4 | const | 1 | | +| 1 | SIMPLE | posts | ALL | NULL | NULL | NULL | NULL | 1 | Using where | ++----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+ +2 rows in set (0.00 sec) +</plain> + +under MySQL. + +Active Record performs a pretty printing that emulates the one of the database +shells. So, the same query running with the PostreSQL adapter would yield instead + +<plain> + QUERY PLAN +------------------------------------------------------------------------------ + Nested Loop Left Join (cost=0.00..37.24 rows=8 width=0) + Join Filter: (posts.user_id = users.id) + -> Index Scan using users_pkey on users (cost=0.00..8.27 rows=1 width=4) + Index Cond: (id = 1) + -> Seq Scan on posts (cost=0.00..28.88 rows=8 width=4) + Filter: (posts.user_id = 1) +(6 rows) +</plain> + +Eager loading may trigger more than one query under the hood, and some queries +may need the results of previous ones. Because of that, +explain+ actually +executes the query, and then asks for the query plans. For example, + +<ruby> +User.where(:id => 1).includes(:posts).explain +</ruby> + +yields + +<plain> ++----+-------------+-------+-------+---------------+---------+---------+-------+------+------- +| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ++----+-------------+-------+-------+---------------+---------+---------+-------+------+------- +| 1 | SIMPLE | users | const | PRIMARY | PRIMARY | 4 | const | 1 | | ++----+-------------+-------+-------+---------------+---------+---------+-------+------+------- +1 row in set (0.00 sec) ++----+-------------+-------+------+---------------+------+---------+------+------+------------- +| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ++----+-------------+-------+------+---------------+------+---------+------+------+------------- +| 1 | SIMPLE | posts | ALL | NULL | NULL | NULL | NULL | 1 | Using where | ++----+-------------+-------+------+---------------+------+---------+------+------+------------- +1 row in set (0.00 sec) +</plain> + +under MySQL. diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 10fdfdd8a9..0a79d6e86e 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -139,11 +139,13 @@ module Rails <<-GEMFILE.strip_heredoc gem 'rails', :path => '#{Rails::Generators::RAILS_DEV_PATH}' gem 'journey', :git => 'git://github.com/rails/journey.git' + gem 'arel', :git => 'git://github.com/rails/arel.git' GEMFILE elsif options.edge? <<-GEMFILE.strip_heredoc gem 'rails', :git => 'git://github.com/rails/rails.git' gem 'journey', :git => 'git://github.com/rails/journey.git' + gem 'arel', :git => 'git://github.com/rails/arel.git' GEMFILE else <<-GEMFILE.strip_heredoc |