Merge branch 'main' into button-to-authenticity-token

This commit is contained in:
Guillermo Iguaran 2021-11-14 11:36:48 -08:00 committed by GitHub
commit b667e48b22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 1169 additions and 361 deletions

@ -17,7 +17,7 @@
s.email = ["pratiknaik@gmail.com", "david@loudthinking.com"]
s.homepage = "https://rubyonrails.org"
s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*", "app/assets/javascripts/action_cable.js"]
s.files = Dir["CHANGELOG.md", "MIT-LICENSE", "README.md", "lib/**/*", "app/assets/javascripts/*.js"]
s.require_path = "lib"
s.metadata = {

@ -435,6 +435,9 @@ module ActionMailer
# * <tt>deliveries</tt> - Keeps an array of all the emails sent out through the Action Mailer with
# <tt>delivery_method :test</tt>. Most useful for unit and functional testing.
#
# * <tt>delivery_job</tt> - The job class used with <tt>deliver_later</tt>. Defaults to
# +ActionMailer::MailDeliveryJob+.
#
# * <tt>deliver_later_queue_name</tt> - The name of the queue used with <tt>deliver_later</tt>.
class Base < AbstractController::Base
include DeliveryMethods

@ -1,3 +1,29 @@
* Raise `ActionController::Redirecting::UnsafeRedirectError` for unsafe `redirect_to` redirects.
This allows `rescue_from` to be used to add a default fallback route:
```ruby
rescue_from ActionController::Redirecting::UnsafeRedirectError do
redirect_to root_url
end
```
*Kasper Timm Hansen*, *Chris Oliver*
* Add `url_from` to verify a redirect location is internal.
Takes the open redirect protection from `redirect_to` so users can wrap a
param, and fall back to an alternate redirect URL when the param provided
one is unsafe.
```ruby
def create
redirect_to url_from(params[:redirect_url]) || root_url
end
```
*dmcge*, *Kasper Timm Hansen*
* Allow Capybara driver name overrides in `SystemTestCase::driven_by`
Allow users to prevent conflicts among drivers that use the same driver

@ -148,6 +148,7 @@ def process(action, *args)
@_response_body = nil
ActiveSupport::ExecutionContext[:controller] = self
process_action(action_name, *args)
end

@ -37,7 +37,6 @@ module ActionController
autoload :Logging
autoload :MimeResponds
autoload :ParamsWrapper
autoload :QueryTags
autoload :Redirecting
autoload :Renderers
autoload :Rendering

@ -1,16 +0,0 @@
# frozen_string_literal: true
module ActionController
module QueryTags # :nodoc:
extend ActiveSupport::Concern
included do
around_action :expose_controller_to_query_logs
end
private
def expose_controller_to_query_logs(&block)
ActiveRecord::QueryLogs.set_context(controller: self, &block)
end
end
end

@ -7,6 +7,8 @@ module Redirecting
include AbstractController::Logger
include ActionController::UrlFor
class UnsafeRedirectError < StandardError; end
included do
mattr_accessor :raise_on_open_redirects, default: false
end
@ -61,20 +63,34 @@ module Redirecting
#
# redirect_to post_url(@post) and return
#
# Passing user input directly into +redirect_to+ is considered dangerous (e.g. `redirect_to(params[:location])`).
# Always use regular expressions or a permitted list when redirecting to a user specified location.
# === Open Redirect protection
#
# By default, Rails protects against redirecting to external hosts for your app's safety, so called open redirects.
# Note: this was a new default in Rails 7.0, after upgrading opt-in by uncommenting the line with +raise_on_open_redirects+ in <tt>config/initializers/new_framework_defaults_7_0.rb</tt>
#
# Here #redirect_to automatically validates the potentially-unsafe URL:
#
# redirect_to params[:redirect_url]
#
# Raises UnsafeRedirectError in the case of an unsafe redirect.
#
# To allow any external redirects pass `allow_other_host: true`, though using a user-provided param in that case is unsafe.
#
# redirect_to "https://rubyonrails.org", allow_other_host: true
#
# See #url_from for more information on what an internal and safe URL is, or how to fall back to an alternate redirect URL in the unsafe case.
def redirect_to(options = {}, response_options = {})
response_options[:allow_other_host] ||= _allow_other_host unless response_options.key?(:allow_other_host)
raise ActionControllerError.new("Cannot redirect to nil!") unless options
raise AbstractController::DoubleRenderError if response_body
allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host }
self.status = _extract_redirect_to_status(options, response_options)
self.location = _compute_safe_redirect_to_location(request, options, response_options)
self.location = _enforce_open_redirect_protection(_compute_redirect_to_location(request, options), allow_other_host: allow_other_host)
self.response_body = "<html><body>You are being <a href=\"#{ERB::Util.unwrapped_html_escape(response.location)}\">redirected</a>.</body></html>"
end
# Soft deprecated alias for <tt>redirect_back_or_to</tt> where the fallback_location location is supplied as a keyword argument instead
# Soft deprecated alias for #redirect_back_or_to where the +fallback_location+ location is supplied as a keyword argument instead
# of the first positional argument.
def redirect_back(fallback_location:, allow_other_host: _allow_other_host, **args)
redirect_back_or_to fallback_location, allow_other_host: allow_other_host, **args
@ -103,23 +119,11 @@ def redirect_back(fallback_location:, allow_other_host: _allow_other_host, **arg
# All other options that can be passed to #redirect_to are accepted as
# options and the behavior is identical.
def redirect_back_or_to(fallback_location, allow_other_host: _allow_other_host, **options)
location = request.referer || fallback_location
location = fallback_location unless allow_other_host || _url_host_allowed?(request.referer)
allow_other_host = true if _allow_other_host && !allow_other_host # if the fallback is an open redirect
redirect_to location, allow_other_host: allow_other_host, **options
end
def _compute_safe_redirect_to_location(request, options, response_options)
location = _compute_redirect_to_location(request, options)
if response_options[:allow_other_host] || _url_host_allowed?(location)
location
if request.referer && (allow_other_host || _url_host_allowed?(request.referer))
redirect_to request.referer, allow_other_host: allow_other_host, **options
else
raise(ArgumentError, <<~MSG.squish)
Unsafe redirect #{location.truncate(100).inspect},
use :allow_other_host to redirect anyway.
MSG
# The method level `allow_other_host` doesn't apply in the fallback case, omit and let the `redirect_to` handling take over.
redirect_to fallback_location, **options
end
end
@ -143,6 +147,30 @@ def _compute_redirect_to_location(request, options) # :nodoc:
module_function :_compute_redirect_to_location
public :_compute_redirect_to_location
# Verifies the passed +location+ is an internal URL that's safe to redirect to and returns it, or nil if not.
# Useful to wrap a params provided redirect URL and fallback to an alternate URL to redirect to:
#
# redirect_to url_from(params[:redirect_url]) || root_url
#
# The +location+ is considered internal, and safe, if it's on the same host as <tt>request.host</tt>:
#
# # If request.host is example.com:
# url_from("https://example.com/profile") # => "https://example.com/profile"
# url_from("http://example.com/profile") # => "http://example.com/profile"
# url_from("http://evil.com/profile") # => nil
#
# Subdomains are considered part of the host:
#
# # If request.host is on https://example.com or https://app.example.com, you'd get:
# url_from("https://dev.example.com/profile") # => nil
#
# NOTE: there's a similarity with {url_for}[rdoc-ref:ActionDispatch::Routing::UrlFor#url_for], which generates an internal URL from various options from within the app, e.g. <tt>url_for(@post)</tt>.
# However, #url_from is meant to take an external parameter to verify as in <tt>url_from(params[:redirect_url])</tt>.
def url_from(location)
location = location.presence
location if location && _url_host_allowed?(location)
end
private
def _allow_other_host
!raise_on_open_redirects
@ -158,6 +186,14 @@ def _extract_redirect_to_status(options, response_options)
end
end
def _enforce_open_redirect_protection(location, allow_other_host:)
if allow_other_host || _url_host_allowed?(location)
location
else
raise UnsafeRedirectError, "Unsafe redirect to #{location.truncate(100).inspect}, pass allow_other_host: true to redirect anyway."
end
end
def _url_host_allowed?(url)
URI(url.to_s).host == request.host
rescue ArgumentError, URI::Error

@ -113,10 +113,6 @@ class Railtie < Rails::Railtie # :nodoc:
if query_logs_tags_enabled
app.config.active_record.query_log_tags += [:controller, :action]
ActiveSupport.on_load(:action_controller) do
include ActionController::QueryTags
end
ActiveSupport.on_load(:active_record) do
ActiveRecord::QueryLogs.taggings.merge!(
controller: ->(context) { context[:controller]&.controller_name },

@ -88,6 +88,10 @@ def unsafe_redirect_back
redirect_back_or_to "http://www.rubyonrails.org/"
end
def safe_redirect_with_fallback
redirect_to url_from(params[:redirect_url]) || "/fallback"
end
def redirect_back_with_explicit_fallback_kwarg
redirect_back(fallback_location: "/things/stuff", status: 307)
end
@ -478,27 +482,41 @@ def test_redirect_to_with_block_and_accepted_options
def test_unsafe_redirect
with_raise_on_open_redirects do
error = assert_raise(ArgumentError) do
error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do
get :unsafe_redirect
end
assert_equal(<<~MSG.squish, error.message)
Unsafe redirect \"http://www.rubyonrails.org/\",
use :allow_other_host to redirect anyway.
MSG
assert_equal "Unsafe redirect to \"http://www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message
end
end
def test_unsafe_redirect_back
with_raise_on_open_redirects do
error = assert_raise(ArgumentError) do
error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do
get :unsafe_redirect_back
end
assert_equal(<<~MSG.squish, error.message)
Unsafe redirect \"http://www.rubyonrails.org/\",
use :allow_other_host to redirect anyway.
MSG
assert_equal "Unsafe redirect to \"http://www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message
end
end
def test_url_from
with_raise_on_open_redirects do
get :safe_redirect_with_fallback, params: { redirect_url: "http://test.host/app" }
assert_response :redirect
assert_redirected_to "http://test.host/app"
end
end
def test_url_from_fallback
with_raise_on_open_redirects do
get :safe_redirect_with_fallback, params: { redirect_url: "http://www.rubyonrails.org/" }
assert_response :redirect
assert_redirected_to "http://test.host/fallback"
get :safe_redirect_with_fallback, params: { redirect_url: "" }
assert_response :redirect
assert_redirected_to "http://test.host/fallback"
end
end

@ -13,6 +13,15 @@
*Sean Doyle*
* Support rendering `<form>` elements _without_ `[action]` attributes by:
* `form_with url: false` or `form_with ..., html: { action: false }`
* `form_for ..., url: false` or `form_for ..., html: { action: false }`
* `form_tag false` or `form_tag ..., action: false`
* `button_to "...", false` or `button_to(false) { ... }`
*Sean Doyle*
* Add `:day_format` option to `date_select`
date_select("article", "written_on", day_format: ->(day) { day.ordinalize })

@ -282,6 +282,12 @@ module FormHelper
# ...
# <% end %>
#
# You can omit the <tt>action</tt> attribute by passing <tt>url: false</tt>:
#
# <%= form_for(@post, url: false) do |f| %>
# ...
# <% end %>
#
# You can also set the answer format, like this:
#
# <%= form_for(@post, format: :json) do |f| %>
@ -449,7 +455,7 @@ def form_for(record, options = {}, &block)
output = capture(builder, &block)
html_options[:multipart] ||= builder.multipart?
html_options = html_options_for_form(options[:url] || {}, html_options)
html_options = html_options_for_form(options.fetch(:url, {}), html_options)
form_tag_with_body(html_options, output)
end
@ -465,10 +471,12 @@ def apply_form_for_options!(record, object, options) # :nodoc:
method: method
)
options[:url] ||= if options.key?(:format)
polymorphic_path(record, format: options.delete(:format))
else
polymorphic_path(record, {})
if options[:url] != false
options[:url] ||= if options.key?(:format)
polymorphic_path(record, format: options.delete(:format))
else
polymorphic_path(record, {})
end
end
end
private :apply_form_for_options!
@ -487,6 +495,15 @@ def apply_form_for_options!(record, object, options) # :nodoc:
# <form action="/posts" method="post">
# <input type="text" name="title">
# </form>
# # With an intentionally empty URL:
# <%= form_with url: false do |form| %>
# <%= form.text_field :title %>
# <% end %>
# # =>
# <form method="post" data-remote="true">
# <input type="text" name="title">
# </form>
#
# # Adding a scope prefixes the input field names:
# <%= form_with scope: :post, url: posts_path do |form| %>
@ -744,7 +761,9 @@ def form_with(model: nil, scope: nil, url: nil, format: nil, **options, &block)
options[:skip_default_ids] = !form_with_generates_ids
if model
url ||= polymorphic_path(model, format: format)
if url != false
url ||= polymorphic_path(model, format: format)
end
model = model.last if model.is_a?(Array)
scope ||= model_name_from_record_or_class(model).param_key
@ -1559,7 +1578,11 @@ def html_options_for_form_with(url_for_options = nil, model = nil, html: {}, loc
# The following URL is unescaped, this is just a hash of options, and it is the
# responsibility of the caller to escape all the values.
html_options[:action] = url_for(url_for_options || {})
if url_for_options == false || html_options[:action] == false
html_options.delete(:action)
else
html_options[:action] = url_for(url_for_options || {})
end
html_options[:"accept-charset"] = "UTF-8"
html_options[:"data-remote"] = true unless local

@ -62,6 +62,9 @@ module FormTagHelper
#
# <%= form_tag('/posts', remote: true) %>
# # => <form action="/posts" method="post" data-remote="true">
# form_tag(false, method: :get)
# # => <form method="get">
#
# form_tag('http://far.away.com/form', authenticity_token: false)
# # form without authenticity token
@ -875,7 +878,11 @@ def html_options_for_form(url_for_options, options)
html_options["enctype"] = "multipart/form-data" if html_options.delete("multipart")
# The following URL is unescaped, this is just a hash of options, and it is the
# responsibility of the caller to escape all the values.
html_options["action"] = url_for(url_for_options)
if url_for_options == false || html_options["action"] == false
html_options.delete("action")
else
html_options["action"] = url_for(url_for_options)
end
html_options["accept-charset"] = "UTF-8"
html_options["data-remote"] = true if html_options.delete("remote")

@ -235,6 +235,20 @@ def method_missing(called, *args, **options, &block)
# # A void element:
# tag.br # => <br>
#
# === Building HTML attributes
#
# Transforms a Hash into HTML attributes, ready to be interpolated into
# ERB. Includes or omits boolean attributes based on their truthiness.
# Transforms keys nested within
# <tt>aria:</tt> or <tt>data:</tt> objects into `aria-` and `data-`
# prefixed attributes:
#
# <input <%= tag.attributes(type: :text, aria: { label: "Search" }) %>>
# # => <input type="text" aria-label="Search">
#
# <button <%= tag.attributes id: "call-to-action", disabled: false, aria: { expanded: false } %> class="primary">Get Started!</button>
# => <button id="call-to-action" aria-expanded="false" class="primary">Get Started!</button>
#
# === Legacy syntax
#
# The following format is for legacy syntax support. It will be deprecated in future versions of Rails.

@ -238,7 +238,15 @@ def link_to(name = nil, options = nil, html_options = nil, &block)
# HTTP verb via the +:method+ option within +html_options+.
#
# ==== Options
# The +options+ hash accepts the same options as +url_for+.
# The +options+ hash accepts the same options as +url_for+. To generate a
# <tt><form></tt> element without an <tt>[action]</tt> attribute, pass
# <tt>false</tt>:
#
# <%= button_to "New", false %>
# # => "<form method="post" class="button_to">
# # <button type="submit">New</button>
# # <input name="authenticity_token" type="hidden" value="10f2163b45388899ad4d5ae948988266befcb6c3d1b2451cf657a0c293d605a6"/>
# # </form>"
#
# Most values in +html_options+ are passed through to the button element,
# but there are a few special options:
@ -324,11 +332,15 @@ def link_to(name = nil, options = nil, html_options = nil, &block)
# #
def button_to(name = nil, options = nil, html_options = nil, &block)
html_options, options = options, name if block_given?
options ||= {}
html_options ||= {}
html_options = html_options.stringify_keys
url = options.is_a?(String) ? options : url_for(options)
url =
case options
when FalseClass then nil
else url_for(options)
end
remote = html_options.delete("remote")
params = html_options.delete("params")

@ -53,7 +53,7 @@ def form_text(action = "http://www.example.com", local: false, **options)
method = method.to_s == "get" ? "get" : "post"
txt = +%{<form accept-charset="UTF-8" action="#{action}"}
txt = +%{<form accept-charset="UTF-8"} + (action ? %{ action="#{action}"} : "")
txt << %{ enctype="multipart/form-data"} if enctype
txt << %{ data-remote="true"} unless local
txt << %{ class="#{html_class}"} if html_class
@ -107,6 +107,22 @@ def test_form_with_with_method_delete
assert_dom_equal expected, actual
end
def test_form_with_false_url
actual = form_with(url: false)
expected = whole_form(false)
assert_dom_equal expected, actual
end
def test_form_with_false_action
actual = form_with(html: { action: false })
expected = whole_form(false)
assert_dom_equal expected, actual
end
def test_form_with_with_local_true
actual = form_with(local: true)
@ -445,6 +461,22 @@ def test_form_with_general_attributes
assert_dom_equal expected, output_buffer
end
def test_form_with_false_url
form_with(url: false)
expected = whole_form(false)
assert_dom_equal expected, output_buffer
end
def test_form_with_model_and_false_url
form_with(model: Post.new, url: false)
expected = whole_form(false)
assert_dom_equal expected, output_buffer
end
def test_form_with_attribute_not_on_model
form_with(model: @post) do |f|
concat f.text_field :this_dont_exist_on_post
@ -2398,7 +2430,7 @@ def hidden_fields(options = {})
end
def form_text(action = "/", id = nil, html_class = nil, local = nil, multipart = nil, method = nil)
txt = +%{<form accept-charset="UTF-8" action="#{action}"}
txt = +%{<form accept-charset="UTF-8"} + (action ? %{ action="#{action}"} : "")
txt << %{ enctype="multipart/form-data"} if multipart
txt << %{ data-remote="true"} unless local
txt << %{ class="#{html_class}"} if html_class

@ -1617,6 +1617,24 @@ def test_form_for_id
assert_dom_equal expected, output_buffer
end
def test_form_for_false_url
form_for(Post.new, url: false) do |form|
end
expected = whole_form(false, "new_post", "new_post")
assert_dom_equal expected, output_buffer
end
def test_form_for_false_action
form_for(Post.new, html: { action: false }) do |form|
end
expected = whole_form(false, "new_post", "new_post")
assert_dom_equal expected, output_buffer
end
def test_field_id_with_model
value = field_id(Post.new, :title)
@ -3738,7 +3756,7 @@ def hidden_fields(options = {})
end
def form_text(action = "/", id = nil, html_class = nil, remote = nil, multipart = nil, method = nil)
txt = +%{<form accept-charset="UTF-8" action="#{action}"}
txt = +%{<form accept-charset="UTF-8"} + (action ? %{ action="#{action}"} : "")
txt << %{ enctype="multipart/form-data"} if multipart
txt << %{ data-remote="true"} if remote
txt << %{ class="#{html_class}"} if html_class

@ -42,7 +42,7 @@ def form_text(action = "http://www.example.com", options = {})
method = method.to_s == "get" ? "get" : "post"
txt = +%{<form accept-charset="UTF-8" action="#{action}"}
txt = +%{<form accept-charset="UTF-8"} + (action ? %{ action="#{action}"} : "")
txt << %{ enctype="multipart/form-data"} if enctype
txt << %{ data-remote="true"} if remote
txt << %{ class="#{html_class}"} if html_class
@ -138,6 +138,20 @@ def test_form_tag_with_remote_false
assert_dom_equal expected, actual
end
def test_form_tag_with_false_url_for_options
actual = form_tag(false)
expected = whole_form(false)
assert_dom_equal expected, actual
end
def test_form_tag_with_false_action
actual = form_tag({}, action: false)
expected = whole_form(false)
assert_dom_equal expected, actual
end
def test_form_tag_enforce_utf8_true
actual = form_tag({}, { enforce_utf8: true })
expected = whole_form("http://www.example.com", enforce_utf8: true)

@ -194,6 +194,20 @@ def test_button_to_with_path
)
end
def test_button_to_with_false_url
assert_dom_equal(
%{<form method="post" class="button_to"><button type="submit">Hello</button></form>},
button_to("Hello", false)
)
end
def test_button_to_with_false_url_and_block
assert_dom_equal(
%{<form method="post" class="button_to"><button type="submit">Hello</button></form>},
button_to(false) { "Hello" }
)
end
def test_button_to_with_straight_url_and_request_forgery
self.request_forgery = true

@ -54,6 +54,7 @@ def perform(*)
private
def _perform_job
ActiveSupport::ExecutionContext[:job] = self
run_callbacks :perform do
perform(*arguments)
end

@ -1,16 +0,0 @@
# frozen_string_literal: true
module ActiveJob
module QueryTags # :nodoc:
extend ActiveSupport::Concern
included do
around_perform :expose_job_to_query_logs
end
private
def expose_job_to_query_logs(&block)
ActiveRecord::QueryLogs.set_context(job: self, &block)
end
end
end

@ -65,10 +65,6 @@ class Railtie < Rails::Railtie # :nodoc:
if query_logs_tags_enabled
app.config.active_record.query_log_tags << :job
ActiveSupport.on_load(:active_job) do
include ActiveJob::QueryTags
end
ActiveSupport.on_load(:active_record) do
ActiveRecord::QueryLogs.taggings[:job] = ->(context) { context[:job].class.name if context[:job] }
end

@ -109,7 +109,7 @@ def queue_adapter_for_test
# end
# end
#
# +:only+ and +:except+ options accepts Class, Array of Class or Proc. When passed a Proc,
# +:only+ and +:except+ options accept Class, Array of Class or Proc. When passed a Proc,
# a hash containing the job's class and it's argument are passed as argument.
#
# Asserts the number of times a job is enqueued to a specific queue by passing +:queue+ option.
@ -168,7 +168,7 @@ def assert_enqueued_jobs(number, only: nil, except: nil, queue: nil, &block)
# end
# end
#
# +:only+ and +:except+ options accepts Class, Array of Class or Proc. When passed a Proc,
# +:only+ and +:except+ options accept Class, Array of Class or Proc. When passed a Proc,
# a hash containing the job's class and it's argument are passed as argument.
#
# Asserts that no jobs are enqueued to a specific queue by passing +:queue+ option
@ -325,7 +325,7 @@ def assert_performed_jobs(number, only: nil, except: nil, queue: nil, &block)
# end
# end
#
# +:only+ and +:except+ options accepts Class, Array of Class or Proc. When passed a Proc,
# +:only+ and +:except+ options accept Class, Array of Class or Proc. When passed a Proc,
# an instance of the job will be passed as argument.
#
# If the +:queue+ option is specified,
@ -417,8 +417,20 @@ def assert_enqueued_with(job: nil, args: nil, at: nil, queue: nil, priority: nil
end
end
matching_class = potential_matches.select do |enqueued_job|
enqueued_job["job_class"] == job.to_s
end
message = +"No enqueued job found with #{expected}"
message << "\n\nPotential matches: #{potential_matches.join("\n")}" if potential_matches.present?
if potential_matches.empty?
message << "\n\nNo jobs were enqueued"
elsif matching_class.empty?
message << "\n\nNo jobs of class #{expected[:job]} were enqueued, job classes enqueued: "
message << potential_matches.map { |job| job["job_class"] }.join(", ")
else
message << "\n\nPotential matches: #{matching_class.join("\n")}"
end
assert matching_job, message
instantiate_job(matching_job)
end
@ -507,8 +519,20 @@ def assert_performed_with(job: nil, args: nil, at: nil, queue: nil, priority: ni
end
end
matching_class = potential_matches.select do |enqueued_job|
enqueued_job["job_class"] == job.to_s
end
message = +"No performed job found with #{expected}"
message << "\n\nPotential matches: #{potential_matches.join("\n")}" if potential_matches.present?
if potential_matches.empty?
message << "\n\nNo jobs were performed"
elsif matching_class.empty?
message << "\n\nNo jobs of class #{expected[:job]} were performed, job classes performed: "
message << potential_matches.map { |job| job["job_class"] }.join(", ")
else
message << "\n\nPotential matches: #{matching_class.join("\n")}"
end
assert matching_job, message
instantiate_job(matching_job)
@ -555,7 +579,7 @@ def assert_performed_with(job: nil, args: nil, at: nil, queue: nil, priority: ni
# assert_performed_jobs 1
# end
#
# +:only+ and +:except+ options accepts Class, Array of Class or Proc. When passed a Proc,
# +:only+ and +:except+ options accept Class, Array of Class or Proc. When passed a Proc,
# an instance of the job will be passed as argument.
#
# If the +:queue+ option is specified,

@ -698,10 +698,35 @@ def test_assert_enqueued_with_failure_with_global_id_args
HelloJob.perform_later(ricardo)
end
end
assert_match(/No enqueued job found with {:job=>HelloJob, :args=>\[#{wilma.inspect}\]}/, error.message)
assert_match(/Potential matches: {.*?:job=>HelloJob, :args=>\[#<Person.* @id="9">\], :queue=>"default".*?}/, error.message)
end
def test_show_jobs_that_are_enqueued_when_job_is_not_queued_at_all
ricardo = Person.new(9)
wilma = Person.new(11)
error = assert_raise ActiveSupport::TestCase::Assertion do
assert_enqueued_with(job: MultipleKwargsJob, args: [wilma]) do
HelloJob.perform_later(ricardo)
end
end
assert_match(/No enqueued job found with {:job=>MultipleKwargsJob, :args=>\[#{wilma.inspect}\]}/, error.message)
assert_match(/No jobs of class MultipleKwargsJob were enqueued, job classes enqueued: HelloJob/, error.message)
end
def test_shows_no_jobs_enqueued_when_there_are_no_jobs
error = assert_raise ActiveSupport::TestCase::Assertion do
assert_enqueued_with(job: HelloJob, args: []) do
end
end
assert_match(/No enqueued job found with {:job=>HelloJob, :args=>\[\]}/, error.message)
assert_match(/No jobs were enqueued/, error.message)
end
def test_assert_enqueued_with_failure_with_no_block_with_global_id_args
ricardo = Person.new(9)
wilma = Person.new(11)
@ -1954,6 +1979,28 @@ def test_assert_performed_with_without_block_failure_with_global_id_args
assert_match(/Potential matches: {.*?:job=>HelloJob, :args=>\[#<Person.* @id="9">\], :queue=>"default".*?}/, error.message)
end
def test_assert_performed_says_no_jobs_performed
error = assert_raise ActiveSupport::TestCase::Assertion do
assert_performed_with(job: HelloJob, args: [])
end
assert_match(/No performed job found with {:job=>HelloJob, :args=>\[\]}/, error.message)
assert_match(/No jobs were performed/, error.message)
end
def test_assert_performed_when_not_matching_the_class_shows_alteratives
ricardo = Person.new(9)
wilma = Person.new(11)
HelloJob.perform_later(ricardo)
perform_enqueued_jobs
error = assert_raise ActiveSupport::TestCase::Assertion do
assert_performed_with(job: MultipleKwargsJob, args: [wilma])
end
assert_match(/No performed job found with {:job=>MultipleKwargsJob, :args=>\[#<Person.* @id=11>\]}/, error.message)
assert_match(/No jobs of class MultipleKwargsJob were performed, job classes performed: HelloJob/, error.message)
end
def test_assert_performed_with_does_not_change_jobs_count
assert_performed_with(job: HelloJob) do
HelloJob.perform_later

@ -208,7 +208,7 @@ def attribute_method_affix(*affixes)
# person.nickname_short? # => true
def alias_attribute(new_name, old_name)
self.attribute_aliases = attribute_aliases.merge(new_name.to_s => old_name.to_s)
CodeGenerator.batch(self, __FILE__, __LINE__) do |code_generator|
ActiveSupport::CodeGenerator.batch(self, __FILE__, __LINE__) do |code_generator|
attribute_method_matchers.each do |matcher|
method_name = matcher.method_name(new_name).to_s
target_name = matcher.method_name(old_name).to_s
@ -274,7 +274,7 @@ def attribute_alias(name)
# end
# end
def define_attribute_methods(*attr_names)
CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner|
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner|
attr_names.flatten.each { |attr_name| define_attribute_method(attr_name, _owner: owner) }
end
end
@ -309,7 +309,7 @@ def define_attribute_methods(*attr_names)
# person.name # => "Bob"
# person.name_short? # => true
def define_attribute_method(attr_name, _owner: generated_attribute_methods)
CodeGenerator.batch(_owner, __FILE__, __LINE__) do |owner|
ActiveSupport::CodeGenerator.batch(_owner, __FILE__, __LINE__) do |owner|
attribute_method_matchers.each do |matcher|
method_name = matcher.method_name(attr_name)
@ -358,69 +358,6 @@ def undefine_attribute_methods
end
private
class CodeGenerator # :nodoc:
class MethodSet
METHOD_CACHES = Hash.new { |h, k| h[k] = Module.new }
def initialize(namespace)
@cache = METHOD_CACHES[namespace]
@sources = []
@methods = {}
end
def define_cached_method(name, as: name)
name = name.to_sym
as = as.to_sym
@methods.fetch(name) do
unless @cache.method_defined?(as)
yield @sources
end
@methods[name] = as
end
end
def apply(owner, path, line)
unless @sources.empty?
@cache.module_eval("# frozen_string_literal: true\n" + @sources.join(";"), path, line)
end
@methods.each do |name, as|
owner.define_method(name, @cache.instance_method(as))
end
end
end
class << self
def batch(owner, path, line)
if owner.is_a?(CodeGenerator)
yield owner
else
instance = new(owner, path, line)
result = yield instance
instance.execute
result
end
end
end
def initialize(owner, path, line)
@owner = owner
@path = path
@line = line
@namespaces = Hash.new { |h, k| h[k] = MethodSet.new(k) }
end
def define_cached_method(name, namespace:, as: name, &block)
@namespaces[namespace].define_cached_method(name, as: as, &block)
end
def execute
@namespaces.each_value do |method_set|
method_set.apply(@owner, @path, @line - 1)
end
end
end
private_constant :CodeGenerator
def generated_attribute_methods
@generated_attribute_methods ||= Module.new.tap { |mod| include mod }
end

@ -1,3 +1,79 @@
* Fix regression bug that caused ignoring additional conditions for preloading has_many-through relations.
Fixes #43132
*Alexander Pauly*
* Fix `has_many` inversing recursion on models with recursive associations.
*Gannon McGibbon*
* Add `accepts_nested_attributes_for` support for `delegated_type`
```ruby
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[ Message Comment ]
accepts_nested_attributes_for :entryable
end
entry = Entry.create(entryable_type: 'Message', entryable_attributes: { content: 'Hello world' })
# => #<Entry:0x00>
# id: 1
# entryable_id: 1,
# entryable_type: 'Message'
# ...>
entry.entryable
# => #<Message:0x01>
# id: 1
# content: 'Hello world'
# ...>
```
Previously it would raise an error:
```ruby
Entry.create(entryable_type: 'Message', entryable_attributes: { content: 'Hello world' })
# ArgumentError: Cannot build association `entryable'. Are you trying to build a polymorphic one-to-one association?
```
*Sjors Baltus*
* Use subquery for DELETE with GROUP_BY and HAVING clauses.
Prior to this change, deletes with GROUP_BY and HAVING were returning an error.
After this change, GROUP_BY and HAVING are valid clauses in DELETE queries, generating the following query:
```sql
DELETE FROM "posts" WHERE "posts"."id" IN (
SELECT "posts"."id" FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" GROUP BY "posts"."id" HAVING (count(comments.id) >= 2))
) [["flagged", "t"]]
```
*Ignacio Chiazzo Cardarello*
* Use subquery for UPDATE with GROUP_BY and HAVING clauses.
Prior to this change, updates with GROUP_BY and HAVING were being ignored, generating a SQL like this:
```sql
UPDATE "posts" SET "flagged" = ? WHERE "posts"."id" IN (
SELECT "posts"."id" FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"
) [["flagged", "t"]]
```
After this change, GROUP_BY and HAVING clauses are used as a subquery in updates, like this:
```sql
UPDATE "posts" SET "flagged" = ? WHERE "posts"."id" IN (
SELECT "posts"."id" FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"
GROUP BY posts.id HAVING (count(comments.id) >= 2)
) [["flagged", "t"]]
```
*Ignacio Chiazzo Cardarello*
* Add support for setting the filename of the schema or structure dump in the database config.
Applications may now set their the filename or path of the schema / structure dump file in their database configuration.

@ -59,6 +59,18 @@ def corrections
end
end
class InverseOfAssociationRecursiveError < ActiveRecordError # :nodoc:
attr_reader :reflection
def initialize(reflection = nil)
if reflection
@reflection = reflection
super("Inverse association #{reflection.name} (#{reflection.options[:inverse_of].inspect} in #{reflection.class_name}) is recursive.")
else
super("Inverse association is recursive.")
end
end
end
class HasManyThroughAssociationNotFoundError < ActiveRecordError # :nodoc:
attr_reader :owner_class, :reflection

@ -263,7 +263,7 @@ def owner_key_type
end
def reflection_scope
@reflection_scope ||= reflection.join_scopes(klass.arel_table, klass.predicate_builder, klass).inject(&:merge!) || klass.unscoped
@reflection_scope ||= reflection.join_scopes(klass.arel_table, klass.predicate_builder, klass).inject(klass.unscoped, &:merge!)
end
def build_scope

@ -778,7 +778,7 @@ def rename_column(table_name, column_name, new_column_name)
#
# CREATE INDEX by_name_surname ON accounts(name(10), surname(15))
#
# Note: SQLite doesn't support index length.
# Note: only supported by MySQL
#
# ====== Creating an index with a sort order (desc or asc, asc is the default)
#

@ -641,6 +641,14 @@ def database_version # :nodoc:
def check_version # :nodoc:
end
# Returns the version identifier of the schema currently available in
# the database. This is generally equal to the number of the highest-
# numbered migration that has been executed, or 0 if no schema
# information is present / the database is empty.
def schema_version
migration_context.current_version
end
def field_ordered_value(column, values) # :nodoc:
node = Arel::Nodes::Case.new(column)
values.each.with_index(1) do |value, order|

@ -208,7 +208,7 @@ def ignored_table?(table_name)
end
def reset_version!
@version = connection.migration_context.current_version
@version = connection.schema_version
end
def derive_columns_hash_and_deduplicate_values

@ -137,6 +137,21 @@ module ActiveRecord
# end
#
# Now you can list a bunch of entries, call +Entry#title+, and polymorphism will provide you with the answer.
#
# == Nested Attributes
#
# Enabling nested attributes on a delegated_type association allows you to
# create the entry and message in one go:
#
# class Entry < ApplicationRecord
# delegated_type :entryable, types: %w[ Message Comment ]
# accepts_nested_attributes_for :entryable
# end
#
# params = { entry: { entryable_type: 'Message', entryable_attributes: { subject: 'Smiling' } } }
# entry = Entry.create(params[:entry])
# entry.entryable.id # => 2
# entry.entryable.subject # => 'Smiling'
module DelegatedType
# Defines this as a class that'll delegate its type for the passed +role+ to the class references in +types+.
# That'll create a polymorphic +belongs_to+ relationship to that +role+, and it'll add all the delegated
@ -207,6 +222,10 @@ def define_delegated_type_methods(role, types:, options:)
public_send("#{role}_class").model_name.singular.inquiry
end
define_method "build_#{role}" do |*params|
public_send("#{role}=", public_send("#{role}_class").new(*params))
end
types.each do |type|
scope_name = type.tableize.tr("/", "_")
singular = scope_name.singularize

@ -44,17 +44,17 @@ module ActiveRecord
# ]
# ActiveRecord::QueryLogs.tags = tags
#
# The QueryLogs +context+ can be manipulated via the +set_context+ method.
# The QueryLogs +context+ can be manipulated via the +ActiveSupport::ExecutionContext.set+ method.
#
# Temporary updates limited to the execution of a block:
#
# ActiveRecord::QueryLogs.set_context(foo: Bar.new) do
# ActiveSupport::ExecutionContext.set(foo: Bar.new) do
# posts = Post.all
# end
#
# Direct updates to a context value:
#
# ActiveRecord::QueryLogs.set_context(foo: Bar.new)
# ActiveSupport::ExecutionContext[:foo] = Bar.new
#
# Tag comments can be prepended to the query:
#
@ -76,30 +76,6 @@ module QueryLogs
thread_mattr_accessor :cached_comment, instance_accessor: false
class << self
# Updates the context used to construct tags in the SQL comment.
# If a block is given, it resets the provided keys to their
# previous value once the block exits.
def set_context(**options)
options.symbolize_keys!
keys = options.keys
previous_context = keys.zip(context.values_at(*keys)).to_h
context.merge!(options)
self.cached_comment = nil
if block_given?
begin
yield
ensure
context.merge!(previous_context)
self.cached_comment = nil
end
end
end
def clear_context # :nodoc:
context.clear
end
def call(sql) # :nodoc:
if prepend_comment
"#{self.comment} #{sql}"
@ -108,6 +84,12 @@ def call(sql) # :nodoc:
end.strip
end
def clear_cache # :nodoc:
self.cached_comment = nil
end
ActiveSupport::ExecutionContext.after_change { ActiveRecord::QueryLogs.clear_cache }
private
# Returns an SQL comment +String+ containing the query log tags.
# Sets and returns a cached comment if <tt>cache_query_log_tags</tt> is +true+.
@ -126,15 +108,13 @@ def uncached_comment
end
end
def context
Thread.current[:active_record_query_log_tags_context] ||= {}
end
def escape_sql_comment(content)
content.to_s.gsub(%r{ (/ (?: | \g<1>) \*) \+? \s* | \s* (\* (?: | \g<2>) /) }x, "")
end
def tag_content
context = ActiveSupport::ExecutionContext.to_h
tags.flat_map { |i| [*i] }.filter_map do |tag|
key, handler = tag
handler ||= taggings[key]

@ -376,10 +376,6 @@ class Railtie < Rails::Railtie # :nodoc:
if app.config.active_record.cache_query_log_tags
ActiveRecord::QueryLogs.cache_query_log_tags = true
end
app.reloader.before_class_unload { ActiveRecord::QueryLogs.clear_context }
app.executor.to_run { ActiveRecord::QueryLogs.clear_context }
app.executor.to_complete { ActiveRecord::QueryLogs.clear_context }
end
end
end

@ -332,7 +332,7 @@ db_namespace = namespace :db do
desc "Retrieves the current schema version number"
task version: :load_config do
puts "Current version: #{ActiveRecord::Base.connection.migration_context.current_version}"
puts "Current version: #{ActiveRecord::Base.connection.schema_version}"
end
# desc "Raises an error if there are pending migrations"

@ -235,6 +235,9 @@ def check_validity_of_inverse!
if has_inverse? && inverse_of.nil?
raise InverseOfAssociationNotFoundError.new(self)
end
if has_inverse? && inverse_of == self
raise InverseOfAssociationRecursiveError.new(self)
end
end
end
@ -632,6 +635,7 @@ def automatic_inverse_of
# with the current reflection's klass name.
def valid_inverse_reflection?(reflection)
reflection &&
reflection != self &&
foreign_key == reflection.foreign_key &&
klass <= reflection.active_record &&
can_find_inverse_of_automatically?(reflection, true)

@ -11,7 +11,7 @@ class Relation
:reverse_order, :distinct, :create_with, :skip_query_cache]
CLAUSE_METHODS = [:where, :having, :from]
INVALID_METHODS_FOR_DELETE_ALL = [:distinct, :group, :having]
INVALID_METHODS_FOR_DELETE_ALL = [:distinct]
VALUE_METHODS = MULTI_VALUE_METHODS + SINGLE_VALUE_METHODS + CLAUSE_METHODS
@ -485,8 +485,9 @@ def update_all(updates)
arel = eager_loading? ? apply_join_dependency.arel : build_arel
arel.source.left = table
stmt = arel.compile_update(values, table[primary_key])
group_values_arel_columns = arel_columns(group_values.uniq)
having_clause_ast = having_clause.ast unless having_clause.empty?
stmt = arel.compile_update(values, table[primary_key], having_clause_ast, group_values_arel_columns)
klass.connection.update(stmt, "#{klass} Update All").tap { reset }
end
@ -615,7 +616,9 @@ def delete_all
arel = eager_loading? ? apply_join_dependency.arel : build_arel
arel.source.left = table
stmt = arel.compile_delete(table[primary_key])
group_values_arel_columns = arel_columns(group_values.uniq)
having_clause_ast = having_clause.ast unless having_clause.empty?
stmt = arel.compile_delete(table[primary_key], having_clause_ast, group_values_arel_columns)
klass.connection.delete(stmt, "#{klass} Delete All").tap { reset }
end

@ -14,7 +14,12 @@ def create_insert
InsertManager.new
end
def compile_update(values, key = nil)
def compile_update(
values,
key = nil,
having_clause = nil,
group_values_columns = []
)
um = UpdateManager.new(source)
um.set(values)
um.take(limit)
@ -22,16 +27,21 @@ def compile_update(values, key = nil)
um.order(*orders)
um.wheres = constraints
um.key = key
um.group(group_values_columns) unless group_values_columns.empty?
um.having(having_clause) unless having_clause.nil?
um
end
def compile_delete(key = nil)
def compile_delete(key = nil, having_clause = nil, group_values_columns = [])
dm = DeleteManager.new(source)
dm.take(limit)
dm.offset(offset)
dm.order(*orders)
dm.wheres = constraints
dm.key = key
dm.group(group_values_columns) unless group_values_columns.empty?
dm.having(having_clause) unless having_clause.nil?
dm
end
end

@ -12,5 +12,21 @@ def from(relation)
@ast.relation = relation
self
end
def group(columns)
columns.each do |column|
column = Nodes::SqlLiteral.new(column) if String === column
column = Nodes::SqlLiteral.new(column.to_s) if Symbol === column
@ast.groups.push Nodes::Group.new column
end
self
end
def having(expr)
@ast.havings << expr
self
end
end
end

@ -3,12 +3,14 @@
module Arel # :nodoc: all
module Nodes
class DeleteStatement < Arel::Nodes::Node
attr_accessor :relation, :wheres, :orders, :limit, :offset, :key
attr_accessor :relation, :wheres, :groups, :havings, :orders, :limit, :offset, :key
def initialize(relation = nil, wheres = [])
super()
@relation = relation
@wheres = wheres
@groups = []
@havings = []
@orders = []
@limit = nil
@offset = nil
@ -30,6 +32,8 @@ def eql?(other)
self.relation == other.relation &&
self.wheres == other.wheres &&
self.orders == other.orders &&
self.groups == other.groups &&
self.havings == other.havings &&
self.limit == other.limit &&
self.offset == other.offset &&
self.key == other.key

@ -3,13 +3,15 @@
module Arel # :nodoc: all
module Nodes
class UpdateStatement < Arel::Nodes::Node
attr_accessor :relation, :wheres, :values, :orders, :limit, :offset, :key
attr_accessor :relation, :wheres, :values, :groups, :havings, :orders, :limit, :offset, :key
def initialize(relation = nil)
super()
@relation = relation
@wheres = []
@values = []
@groups = []
@havings = []
@orders = []
@limit = nil
@offset = nil
@ -31,6 +33,8 @@ def eql?(other)
self.relation == other.relation &&
self.wheres == other.wheres &&
self.values == other.values &&
self.groups == other.groups &&
self.havings == other.havings &&
self.orders == other.orders &&
self.limit == other.limit &&
self.offset == other.offset &&

@ -28,5 +28,21 @@ def set(values)
end
self
end
def group(columns)
columns.each do |column|
column = Nodes::SqlLiteral.new(column) if String === column
column = Nodes::SqlLiteral.new(column.to_s) if Symbol === column
@ast.groups.push Nodes::Group.new column
end
self
end
def having(expr)
@ast.havings << expr
self
end
end
end

@ -67,7 +67,8 @@ def visit_Arel_Nodes_NullsFirst(o, collector)
# query. However, this does not allow for LIMIT, OFFSET and ORDER. To support
# these, we must use a subquery.
def prepare_update_statement(o)
if o.offset || has_join_sources?(o) && has_limit_or_offset_or_orders?(o)
if o.offset || has_group_by_and_having?(o) ||
has_join_sources?(o) && has_limit_or_offset_or_orders?(o)
super
else
o

@ -841,6 +841,10 @@ def has_limit_or_offset_or_orders?(o)
o.limit || o.offset || !o.orders.empty?
end
def has_group_by_and_having?(o)
!o.groups.empty? && !o.havings.empty?
end
# The default strategy for an UPDATE with joins is to use a subquery. This doesn't work
# on MySQL (even when aliasing the tables), but MySQL allows using JOIN directly in
# an UPDATE statement, so in the MySQL visitor we redefine this to do that.
@ -852,6 +856,8 @@ def prepare_update_statement(o)
stmt.orders = []
stmt.wheres = [Nodes::In.new(o.key, [build_subselect(o.key, o)])]
stmt.relation = o.relation.left if has_join_sources?(o)
stmt.groups = o.groups unless o.groups.empty?
stmt.havings = o.havings unless o.havings.empty?
stmt
else
o
@ -866,6 +872,8 @@ def build_subselect(key, o)
core.froms = o.relation
core.wheres = o.wheres
core.projections = [key]
core.groups = o.groups unless o.groups.empty?
core.havings = o.havings unless o.havings.empty?
stmt.limit = o.limit
stmt.offset = o.offset
stmt.orders = o.orders

@ -213,11 +213,9 @@ def test_numeric_value_out_of_ranges_are_translated_to_specific_exception
def test_exceptions_from_notifications_are_not_translated
original_error = StandardError.new("This StandardError shouldn't get translated")
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") { raise original_error }
wrapped_error = assert_raises(ActiveSupport::Notifications::InstrumentationSubscriberError) do
actual_error = assert_raises(StandardError) do
@connection.execute("SELECT * FROM posts")
end
actual_error = wrapped_error.exceptions.first
assert_equal original_error, actual_error
ensure

@ -49,7 +49,7 @@ def test_schema_define
assert_nothing_raised { @connection.select_all "SELECT * FROM fruits" }
assert_nothing_raised { @connection.select_all "SELECT * FROM schema_migrations" }
assert_equal 7, @connection.migration_context.current_version
assert_equal 7, @connection.schema_version
end
def test_schema_define_with_table_name_prefix

@ -27,6 +27,8 @@
statement1.orders = %w[x y z]
statement1.limit = 42
statement1.key = "zomg"
statement1.groups = ["foo"]
statement1.havings = []
statement2 = Arel::Nodes::UpdateStatement.new
statement2.relation = "zomg"
statement2.wheres = 2
@ -34,6 +36,8 @@
statement2.orders = %w[x y z]
statement2.limit = 42
statement2.key = "zomg"
statement2.groups = ["foo"]
statement2.havings = []
array = [statement1, statement2]
assert_equal 1, array.uniq.size
end

@ -22,6 +22,57 @@ class UpdateManagerTest < Arel::Spec
assert_match(/LIMIT 10/, um.to_sql)
end
describe "having" do
it "sets having" do
users_table = Table.new(:users)
posts_table = Table.new(:posts)
join_source = Arel::Nodes::InnerJoin.new(users_table, posts_table)
update_manager = Arel::UpdateManager.new
update_manager.table(join_source)
update_manager.group(["posts.id"])
update_manager.having("count(posts.id) >= 2")
assert_equal(["count(posts.id) >= 2"], update_manager.ast.havings)
end
end
describe "group" do
it "adds columns to the AST when group value is a String" do
users_table = Table.new(:users)
posts_table = Table.new(:posts)
join_source = Arel::Nodes::InnerJoin.new(users_table, posts_table)
update_manager = Arel::UpdateManager.new
update_manager.table(join_source)
update_manager.group(["posts.id"])
update_manager.having("count(posts.id) >= 2")
assert_equal(1, update_manager.ast.groups.count)
group_ast = update_manager.ast.groups.first
_(group_ast).must_be_kind_of Nodes::Group
assert_equal("posts.id", group_ast.expr)
assert_equal(["count(posts.id) >= 2"], update_manager.ast.havings)
end
it "adds columns to the AST when group value is a Symbol" do
users_table = Table.new(:users)
posts_table = Table.new(:posts)
join_source = Arel::Nodes::InnerJoin.new(users_table, posts_table)
update_manager = Arel::UpdateManager.new
update_manager.table(join_source)
update_manager.group([:"posts.id"])
update_manager.having("count(posts.id) >= 2")
assert_equal(1, update_manager.ast.groups.count)
group_ast = update_manager.ast.groups.first
_(group_ast).must_be_kind_of Nodes::Group
assert_equal("posts.id", group_ast.expr)
assert_equal(["count(posts.id) >= 2"], update_manager.ast.havings)
end
end
describe "set" do
it "updates with null" do
table = Table.new(:users)

@ -25,6 +25,7 @@
require "models/contract"
require "models/subscription"
require "models/book"
require "models/branch"
class AutomaticInverseFindingTests < ActiveRecord::TestCase
fixtures :ratings, :comments, :cars, :books
@ -787,6 +788,30 @@ def test_with_hash_many_inversing_does_not_add_duplicate_associated_objects
end
end
def test_recursive_model_has_many_inversing
with_has_many_inversing do
main = Branch.create!
feature = main.branches.create!
topic = feature.branches.build
assert_equal(main, topic.branch.branch)
end
end
def test_recursive_inverse_on_recursive_model_has_many_inversing
with_has_many_inversing do
main = BrokenBranch.create!
feature = main.branches.create!
topic = feature.branches.build
error = assert_raises(ActiveRecord::InverseOfAssociationRecursiveError) do
topic.branch.branch
end
assert_equal("Inverse association branch (:branch in BrokenBranch) is recursive.", error.message)
end
end
def test_unscope_does_not_set_inverse_when_incorrect
interest = interests(:trainspotting)
human = interest.human

@ -428,6 +428,18 @@ def test_preload_makes_correct_number_of_queries_on_relation
end
end
def test_preload_for_hmt_with_conditions
post = posts(:welcome)
_normal_category = post.categories.create!(name: "Normal")
special_category = post.special_categories.create!(name: "Special")
preloader = ActiveRecord::Associations::Preloader.new(records: [post], associations: :hmt_special_categories)
preloader.call
assert_equal 1, post.hmt_special_categories.length
assert_equal [special_category], post.hmt_special_categories
end
def test_preload_groups_queries_with_same_scope
book = books(:awdr)
post = posts(:welcome)

@ -72,4 +72,9 @@ class DelegatedTypeTest < ActiveRecord::TestCase
assert_equal @uuid_entry_with_comment.entryable_uuid, @uuid_entry_with_comment.uuid_comment_uuid
assert_nil @uuid_entry_with_comment.uuid_message_uuid
end
test "builder method" do
assert_respond_to Entry.new, :build_entryable
assert_equal Message, Entry.new(entryable_type: "Message").build_entryable.class
end
end

@ -11,6 +11,8 @@
require "models/interest"
require "models/owner"
require "models/pet"
require "models/entry"
require "models/message"
require "active_support/hash_with_indifferent_access"
class TestNestedAttributesInGeneral < ActiveRecord::TestCase
@ -1117,3 +1119,15 @@ def test_extend_affects_nested_attributes
assert_equal "from extension", pirate.treasures[0].name
end
end
class TestNestedAttributesForDelegatedType < ActiveRecord::TestCase
setup do
Entry.accepts_nested_attributes_for :entryable
@entry = Entry.new(entryable_type: "Message", entryable_attributes: { subject: "Hello world!" })
end
def test_should_build_a_new_record_based_on_the_delegated_type
assert_not_predicate @entry.entryable, :persisted?
assert_equal "Hello world!", @entry.entryable.subject
end
end

@ -489,13 +489,11 @@ def test_query_cache_does_not_allow_sql_key_mutation
payload[:sql].downcase!
end
error = assert_raises ActiveSupport::Notifications::InstrumentationSubscriberError do
assert_raises FrozenError do
ActiveRecord::Base.cache do
assert_queries(1) { Task.find(1); Task.find(1) }
end
end
assert error.exceptions.first.is_a?(FrozenError)
ensure
ActiveSupport::Notifications.unsubscribe subscriber
end

@ -11,13 +11,14 @@ class QueryLogsTest < ActiveRecord::TestCase
}
def setup
# QueryLogs context is automatically reset in Rails app via an executor hooks set in railtie
# ActiveSupport::ExecutionContext context is automatically reset in Rails app via an executor hooks set in railtie
# But not in Active Record's own test suite.
ActiveRecord::QueryLogs.clear_context
ActiveSupport::ExecutionContext.clear
# Enable the query tags logging
@original_transformers = ActiveRecord.query_transformers
@original_prepend = ActiveRecord::QueryLogs.prepend_comment
@original_tags = ActiveRecord::QueryLogs.tags
ActiveRecord.query_transformers += [ActiveRecord::QueryLogs]
ActiveRecord::QueryLogs.prepend_comment = false
ActiveRecord::QueryLogs.cache_query_log_tags = false
@ -27,10 +28,13 @@ def setup
def teardown
ActiveRecord.query_transformers = @original_transformers
ActiveRecord::QueryLogs.prepend_comment = @original_prepend
ActiveRecord::QueryLogs.tags = []
# QueryLogs context is automatically reset in Rails app via an executor hooks set in railtie
ActiveRecord::QueryLogs.tags = @original_tags
ActiveRecord::QueryLogs.prepend_comment = false
ActiveRecord::QueryLogs.cache_query_log_tags = false
ActiveRecord::QueryLogs.cached_comment = nil
# ActiveSupport::ExecutionContext context is automatically reset in Rails app via an executor hooks set in railtie
# But not in Active Record's own test suite.
ActiveRecord::QueryLogs.clear_context
ActiveSupport::ExecutionContext.clear
end
def test_escaping_good_comment
@ -57,8 +61,6 @@ def test_add_comments_to_beginning_of_query
assert_sql(%r{/\*application:active_record\*/ select id from posts$}) do
ActiveRecord::Base.connection.execute "select id from posts"
end
ensure
ActiveRecord::QueryLogs.prepend_comment = nil
end
def test_exists_is_commented
@ -112,41 +114,24 @@ def test_retrieves_comment_from_cache_when_enabled_and_set
ActiveRecord::QueryLogs.stub(:cached_comment, "/*cached_comment*/") do
assert_equal "/*cached_comment*/", ActiveRecord::QueryLogs.call("")
end
ensure
ActiveRecord::QueryLogs.cached_comment = nil
ActiveRecord::QueryLogs.cache_query_log_tags = false
end
def test_resets_cache_on_context_update
ActiveRecord::QueryLogs.cache_query_log_tags = true
ActiveRecord::QueryLogs.set_context(temporary: "value")
ActiveSupport::ExecutionContext[:temporary] = "value"
ActiveRecord::QueryLogs.tags = [ temporary_tag: ->(context) { context[:temporary] } ]
assert_equal "/*temporary_tag:value*/", ActiveRecord::QueryLogs.call("")
ActiveRecord::QueryLogs.set_context(temporary: "new_value")
ActiveSupport::ExecutionContext[:temporary] = "new_value"
assert_nil ActiveRecord::QueryLogs.cached_comment
assert_equal "/*temporary_tag:new_value*/", ActiveRecord::QueryLogs.call("")
ensure
ActiveRecord::QueryLogs.cached_comment = nil
ActiveRecord::QueryLogs.cache_query_log_tags = false
end
def test_ensure_context_has_symbol_keys
ActiveRecord::QueryLogs.tags = [ new_key: ->(context) { context[:symbol_key] } ]
ActiveRecord::QueryLogs.set_context("symbol_key" => "symbolized")
assert_sql(%r{/\*new_key:symbolized}) do
Dashboard.first
end
ensure
ActiveRecord::QueryLogs.set_context(application_name: nil)
end
def test_default_tag_behavior
ActiveRecord::QueryLogs.tags = [:application, :foo]
ActiveRecord::QueryLogs.set_context(foo: "bar") do
ActiveSupport::ExecutionContext.set(foo: "bar") do
assert_sql(%r{/\*application:active_record,foo:bar\*/}) do
Dashboard.first
end
@ -157,39 +142,29 @@ def test_default_tag_behavior
end
def test_empty_comments_are_not_added
original_tags = ActiveRecord::QueryLogs.tags
ActiveRecord::QueryLogs.tags = [ empty: -> { nil } ]
assert_sql(%r{select id from posts$}) do
ActiveRecord::Base.connection.execute "select id from posts"
end
ensure
ActiveRecord::QueryLogs.tags = original_tags
end
def test_custom_basic_tags
original_tags = ActiveRecord::QueryLogs.tags
ActiveRecord::QueryLogs.tags = [ :application, { custom_string: "test content" } ]
assert_sql(%r{/\*application:active_record,custom_string:test content\*/$}) do
Dashboard.first
end
ensure
ActiveRecord::QueryLogs.tags = original_tags
end
def test_custom_proc_tags
original_tags = ActiveRecord::QueryLogs.tags
ActiveRecord::QueryLogs.tags = [ :application, { custom_proc: -> { "test content" } } ]
assert_sql(%r{/\*application:active_record,custom_proc:test content\*/$}) do
Dashboard.first
end
ensure
ActiveRecord::QueryLogs.tags = original_tags
end
def test_multiple_custom_tags
original_tags = ActiveRecord::QueryLogs.tags
ActiveRecord::QueryLogs.tags = [
:application,
{ custom_proc: -> { "test content" }, another_proc: -> { "more test content" } },
@ -198,34 +173,14 @@ def test_multiple_custom_tags
assert_sql(%r{/\*application:active_record,custom_proc:test content,another_proc:more test content\*/$}) do
Dashboard.first
end
ensure
ActiveRecord::QueryLogs.tags = original_tags
end
def test_custom_proc_context_tags
original_tags = ActiveRecord::QueryLogs.tags
ActiveRecord::QueryLogs.set_context(foo: "bar")
ActiveSupport::ExecutionContext[:foo] = "bar"
ActiveRecord::QueryLogs.tags = [ :application, { custom_context_proc: ->(context) { context[:foo] } } ]
assert_sql(%r{/\*application:active_record,custom_context_proc:bar\*/$}) do
Dashboard.first
end
ensure
ActiveRecord::QueryLogs.set_context(foo: nil)
ActiveRecord::QueryLogs.tags = original_tags
end
def test_set_context_restore_state
original_tags = ActiveRecord::QueryLogs.tags
ActiveRecord::QueryLogs.tags = [foo: ->(context) { context[:foo] }]
ActiveRecord::QueryLogs.set_context(foo: "bar") do
assert_sql(%r{/\*foo:bar\*/$}) { Dashboard.first }
ActiveRecord::QueryLogs.set_context(foo: "plop") do
assert_sql(%r{/\*foo:plop\*/$}) { Dashboard.first }
end
assert_sql(%r{/\*foo:bar\*/$}) { Dashboard.first }
end
ensure
ActiveRecord::QueryLogs.tags = original_tags
end
end

@ -5,9 +5,10 @@
require "models/post"
require "models/pet"
require "models/toy"
require "models/comment"
class DeleteAllTest < ActiveRecord::TestCase
fixtures :authors, :author_addresses, :posts, :pets, :toys
fixtures :authors, :author_addresses, :comments, :posts, :pets, :toys
def test_destroy_all
davids = Author.where(name: "David")
@ -53,10 +54,22 @@ def test_delete_all_loaded
assert_predicate davids, :loaded?
end
def test_delete_all_with_group_by_and_having
minimum_comments_count = 2
posts_to_be_deleted = Post.most_commented(minimum_comments_count).all.to_a
assert_operator posts_to_be_deleted.length, :>, 0
assert_difference("Post.count", -posts_to_be_deleted.length) do
Post.most_commented(minimum_comments_count).delete_all
end
posts_to_be_deleted.each do |deleted_post|
assert_raise(ActiveRecord::RecordNotFound) { deleted_post.reload }
end
end
def test_delete_all_with_unpermitted_relation_raises_error
assert_raises(ActiveRecord::ActiveRecordError) { Author.distinct.delete_all }
assert_raises(ActiveRecord::ActiveRecordError) { Author.group(:name).delete_all }
assert_raises(ActiveRecord::ActiveRecordError) { Author.having("SUM(id) < 3").delete_all }
end
def test_delete_all_with_joins_and_where_part_is_hash

@ -44,6 +44,20 @@ def test_update_all_with_blank_argument
assert_equal "Empty list of attributes to change", error.message
end
def test_update_all_with_group_by
minimum_comments_count = 2
Post.most_commented(minimum_comments_count).update_all(title: "ig")
posts = Post.most_commented(minimum_comments_count).all.to_a
assert_operator posts.length, :>, 0
assert posts.all? { |post| post.comments.length >= minimum_comments_count }
assert posts.all? { |post| "ig" == post.title }
post = Post.joins(:comments).group("posts.id").having("count(comments.id) < #{minimum_comments_count}").first
assert_not_equal "ig", post.title
end
def test_update_all_with_joins
pets = Pet.joins(:toys).where(toys: { name: "Bone" })

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Branch < ActiveRecord::Base
has_many :branches
belongs_to :branch, optional: true
end
class BrokenBranch < Branch
has_many :branches, class_name: "BrokenBranch", foreign_key: :branch_id
belongs_to :branch, optional: true, inverse_of: :branch, class_name: "BrokenBranch"
end

@ -34,6 +34,11 @@ def greeting
scope :limit_by, lambda { |l| limit(l) }
scope :locked, -> { lock }
scope :most_commented, lambda { |comments_count|
joins(:comments)
.group("posts.id")
.having("count(comments.id) >= #{comments_count}")
}
belongs_to :author
belongs_to :readonly_author, -> { readonly }, class_name: "Author", foreign_key: :author_id
@ -109,6 +114,7 @@ def greeting
has_many :category_posts, class_name: "CategoryPost"
has_many :scategories, through: :category_posts, source: :category
has_many :hmt_special_categories, -> { where.not(name: nil) }, through: :category_posts, source: :category, class_name: "SpecialCategory"
has_and_belongs_to_many :categories
has_and_belongs_to_many :special_categories, join_table: "categories_posts", association_foreign_key: "category_id"

@ -145,6 +145,10 @@
t.boolean :has_fun, null: false, default: false
end
create_table :branches, force: true do |t|
t.references :branch
end
create_table :bulbs, primary_key: "ID", force: true do |t|
t.integer :car_id
t.string :name

@ -34,11 +34,13 @@ module ActiveSupport
extend ActiveSupport::Autoload
autoload :Concern
autoload :CodeGenerator
autoload :ActionableError
autoload :ConfigurationFile
autoload :CurrentAttributes
autoload :Dependencies
autoload :DescendantsTracker
autoload :ExecutionContext
autoload :ExecutionWrapper
autoload :Executor
autoload :FileUpdateChecker

@ -0,0 +1,65 @@
# frozen_string_literal: true
module ActiveSupport
class CodeGenerator # :nodoc:
class MethodSet
METHOD_CACHES = Hash.new { |h, k| h[k] = Module.new }
def initialize(namespace)
@cache = METHOD_CACHES[namespace]
@sources = []
@methods = {}
end
def define_cached_method(name, as: name)
name = name.to_sym
as = as.to_sym
@methods.fetch(name) do
unless @cache.method_defined?(as)
yield @sources
end
@methods[name] = as
end
end
def apply(owner, path, line)
unless @sources.empty?
@cache.module_eval("# frozen_string_literal: true\n" + @sources.join(";"), path, line)
end
@methods.each do |name, as|
owner.define_method(name, @cache.instance_method(as))
end
end
end
class << self
def batch(owner, path, line)
if owner.is_a?(CodeGenerator)
yield owner
else
instance = new(owner, path, line)
result = yield instance
instance.execute
result
end
end
end
def initialize(owner, path, line)
@owner = owner
@path = path
@line = line
@namespaces = Hash.new { |h, k| h[k] = MethodSet.new(k) }
end
def define_cached_method(name, namespace:, as: name, &block)
@namespaces[namespace].define_cached_method(name, as: as, &block)
end
def execute
@namespaces.each_value do |method_set|
method_set.apply(@owner, @path, @line - 1)
end
end
end
end

@ -1,9 +1,3 @@
# frozen_string_literal: true
require "active_support/core_ext/digest/uuid"
module ActiveSupport
class << self
delegate :use_rfc4122_namespaced_uuids, :use_rfc4122_namespaced_uuids=, to: :'Digest::UUID'
end
end

@ -98,25 +98,37 @@ def instance
# Declares one or more attributes that will be given both class and instance accessor methods.
def attribute(*names)
generated_attribute_methods.module_eval do
ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner|
names.each do |name|
define_method(name) do
attributes[name.to_sym]
owner.define_cached_method(name, namespace: :current_attributes) do |batch|
batch <<
"def #{name}" <<
"attributes[:#{name}]" <<
"end"
end
define_method("#{name}=") do |attribute|
attributes[name.to_sym] = attribute
owner.define_cached_method("#{name}=", namespace: :current_attributes) do |batch|
batch <<
"def #{name}=(value)" <<
"attributes[:#{name}] = value" <<
"end"
end
end
end
names.each do |name|
define_singleton_method(name) do
instance.public_send(name)
end
define_singleton_method("#{name}=") do |attribute|
instance.public_send("#{name}=", attribute)
ActiveSupport::CodeGenerator.batch(singleton_class, __FILE__, __LINE__) do |owner|
names.each do |name|
owner.define_cached_method(name, namespace: :current_attributes_delegation) do |batch|
batch <<
"def #{name}" <<
"instance.#{name}" <<
"end"
end
owner.define_cached_method("#{name}=", namespace: :current_attributes_delegation) do |batch|
batch <<
"def #{name}=(value)" <<
"instance.#{name} = value" <<
"end"
end
end
end
end

@ -0,0 +1,53 @@
# frozen_string_literal: true
module ActiveSupport
module ExecutionContext # :nodoc:
@after_change_callbacks = []
class << self
def after_change(&block)
@after_change_callbacks << block
end
# Updates the execution context. If a block is given, it resets the provided keys to their
# previous value once the block exits.
def set(**options)
options.symbolize_keys!
keys = options.keys
store = self.store
previous_context = keys.zip(store.values_at(*keys)).to_h
store.merge!(options)
@after_change_callbacks.each(&:call)
if block_given?
begin
yield
ensure
store.merge!(previous_context)
@after_change_callbacks.each(&:call)
end
end
end
def []=(key, value)
store[key.to_sym] = value
@after_change_callbacks.each(&:call)
end
def to_h
store.dup
end
def clear
store.clear
end
private
def store
Thread.current[:active_support_execution_context] ||= {}
end
end
end
end

@ -0,0 +1,13 @@
# frozen_string_literal: true
module ActiveSupport::ExecutionContext::TestHelper # :nodoc:
def before_setup
ActiveSupport::ExecutionContext.clear
super
end
def after_teardown
super
ActiveSupport::ExecutionContext.clear
end
end

@ -93,8 +93,16 @@ def iterate_guarding_exceptions(listeners)
exceptions ||= []
exceptions << e
end
ensure
raise InstrumentationSubscriberError.new(exceptions) unless exceptions.nil?
if exceptions
if exceptions.size == 1
raise exceptions.first
else
raise InstrumentationSubscriberError.new(exceptions), cause: exceptions.first
end
end
listeners
end
def listeners_for(name)

@ -27,6 +27,12 @@ class Railtie < Rails::Railtie # :nodoc:
end
end
initializer "active_support.reset_execution_context" do |app|
app.reloader.before_class_unload { ActiveSupport::ExecutionContext.clear }
app.executor.to_run { ActiveSupport::ExecutionContext.clear }
app.executor.to_complete { ActiveSupport::ExecutionContext.clear }
end
initializer "active_support.reset_all_current_attributes_instances" do |app|
executor_around_test_case = app.config.active_support.delete(:executor_around_test_case)
@ -41,6 +47,9 @@ class Railtie < Rails::Railtie # :nodoc:
else
require "active_support/current_attributes/test_helper"
include ActiveSupport::CurrentAttributes::TestHelper
require "active_support/execution_context/test_helper"
include ActiveSupport::ExecutionContext::TestHelper
end
end
end
@ -133,7 +142,7 @@ class Railtie < Rails::Railtie # :nodoc:
config.after_initialize do
if app.config.active_support.use_rfc4122_namespaced_uuids
require "active_support/core_ext/digest"
ActiveSupport.use_rfc4122_namespaced_uuids = app.config.active_support.use_rfc4122_namespaced_uuids
::Digest::UUID.use_rfc4122_namespaced_uuids = app.config.active_support.use_rfc4122_namespaced_uuids
end
end
end

@ -4,14 +4,6 @@
require "active_support/core_ext/digest"
class DigestUUIDExt < ActiveSupport::TestCase
def with_use_rfc4122_namespaced_uuids_set
old_value = ActiveSupport.use_rfc4122_namespaced_uuids
ActiveSupport.use_rfc4122_namespaced_uuids = true
yield
ensure
ActiveSupport.use_rfc4122_namespaced_uuids = old_value
end
def test_constants
assert_equal "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "%08x-%04x-%04x-%04x-%04x%08x" % Digest::UUID::DNS_NAMESPACE.unpack("NnnnnN")
assert_equal "6ba7b811-9dad-11d1-80b4-00c04fd430c8", "%08x-%04x-%04x-%04x-%04x%08x" % Digest::UUID::URL_NAMESPACE.unpack("NnnnnN")
@ -184,4 +176,13 @@ def test_invalid_hash_class
Digest::UUID.uuid_from_hash(OpenSSL::Digest::SHA256, Digest::UUID::OID_NAMESPACE, "1.2.3")
end
end
private
def with_use_rfc4122_namespaced_uuids_set
old_value = Digest::UUID.use_rfc4122_namespaced_uuids
Digest::UUID.use_rfc4122_namespaced_uuids = true
yield
ensure
Digest::UUID.use_rfc4122_namespaced_uuids = old_value
end
end

@ -1,17 +1,12 @@
# frozen_string_literal: true
require_relative "abstract_unit"
require "active_support/current_attributes/test_helper"
class CurrentAttributesTest < ActiveSupport::TestCase
# CurrentAttributes is automatically reset in Rails app via executor hooks set in railtie
# But not in Active Support's own test suite.
setup do
ActiveSupport::CurrentAttributes.reset_all
end
teardown do
ActiveSupport::CurrentAttributes.reset_all
end
include ActiveSupport::CurrentAttributes::TestHelper
Person = Struct.new(:id, :name, :time_zone)

@ -0,0 +1,47 @@
# frozen_string_literal: true
require_relative "abstract_unit"
require "active_support/execution_context/test_helper"
class ExecutionContextTest < ActiveSupport::TestCase
# ExecutionContext is automatically reset in Rails app via executor hooks set in railtie
# But not in Active Support's own test suite.
include ActiveSupport::ExecutionContext::TestHelper
test "#set restore the modified keys when the block exits" do
assert_nil ActiveSupport::ExecutionContext.to_h[:foo]
ActiveSupport::ExecutionContext.set(foo: "bar") do
assert_equal "bar", ActiveSupport::ExecutionContext.to_h[:foo]
ActiveSupport::ExecutionContext.set(foo: "plop") do
assert_equal "plop", ActiveSupport::ExecutionContext.to_h[:foo]
end
assert_equal "bar", ActiveSupport::ExecutionContext.to_h[:foo]
ActiveSupport::ExecutionContext[:direct_assignment] = "present"
ActiveSupport::ExecutionContext.set(multi_assignment: "present")
end
assert_nil ActiveSupport::ExecutionContext.to_h[:foo]
assert_equal "present", ActiveSupport::ExecutionContext.to_h[:direct_assignment]
assert_equal "present", ActiveSupport::ExecutionContext.to_h[:multi_assignment]
end
test "#set coerce keys to symbol" do
ActiveSupport::ExecutionContext.set("foo" => "bar") do
assert_equal "bar", ActiveSupport::ExecutionContext.to_h[:foo]
end
end
test "#[]= coerce keys to symbol" do
ActiveSupport::ExecutionContext["symbol_key"] = "symbolized"
assert_equal "symbolized", ActiveSupport::ExecutionContext.to_h[:symbol_key]
end
test "#to_h returns a copy of the context" do
ActiveSupport::ExecutionContext[:foo] = 42
context = ActiveSupport::ExecutionContext.to_h
context[:foo] = 43
assert_equal 42, ActiveSupport::ExecutionContext.to_h[:foo]
end
end

@ -92,18 +92,23 @@ def test_listen_to_everything
], listener.events
end
def test_listen_start_exception_consistency
def test_listen_start_multiple_exception_consistency
notifier = Fanout.new
listener = Listener.new
notifier.subscribe nil, BadStartListener.new
notifier.subscribe nil, BadStartListener.new
notifier.subscribe nil, listener
assert_raises InstrumentationSubscriberError do
error = assert_raises InstrumentationSubscriberError do
notifier.start "hello", 1, {}
end
assert_raises InstrumentationSubscriberError do
assert_instance_of BadListenerException, error.cause
error = assert_raises InstrumentationSubscriberError do
notifier.start "world", 1, {}
end
assert_instance_of BadListenerException, error.cause
notifier.finish "world", 1, {}
notifier.finish "hello", 1, {}
@ -116,20 +121,24 @@ def test_listen_start_exception_consistency
], listener.events
end
def test_listen_finish_exception_consistency
def test_listen_finish_multiple_exception_consistency
notifier = Fanout.new
listener = Listener.new
notifier.subscribe nil, BadFinishListener.new
notifier.subscribe nil, BadFinishListener.new
notifier.subscribe nil, listener
notifier.start "hello", 1, {}
notifier.start "world", 1, {}
assert_raises InstrumentationSubscriberError do
error = assert_raises InstrumentationSubscriberError do
notifier.finish "world", 1, {}
end
assert_raises InstrumentationSubscriberError do
assert_instance_of BadListenerException, error.cause
error = assert_raises InstrumentationSubscriberError do
notifier.finish "hello", 1, {}
end
assert_instance_of BadListenerException, error.cause
assert_equal 4, listener.events.length
assert_equal [

@ -26,7 +26,7 @@ namespace :guides do
# Validate guides -------------------------------------------------------------------------
desc 'Validate guides, use ONLY=foo to process just "foo.html"'
task validate: :encoding do
task :validate do
ruby "w3c_validator.rb"
end

@ -390,6 +390,14 @@ def welcome_email
end
```
The same technique works to specify a sender name:
```ruby
class UserMailer < ApplicationMailer
default from: email_address_with_name('notification@example.com', 'Example Company Notifications')
end
```
If the name is a blank string, it returns just the address.
[`email_address_with_name`]: https://api.rubyonrails.org/classes/ActionMailer/Base.html#method-i-email_address_with_name
@ -810,6 +818,8 @@ files (environment.rb, production.rb, etc...)
|`delivery_method`|Defines a delivery method. Possible values are:<ul><li>`:smtp` (default), can be configured by using `config.action_mailer.smtp_settings`.</li><li>`:sendmail`, can be configured by using `config.action_mailer.sendmail_settings`.</li><li>`:file`: save emails to files; can be configured by using `config.action_mailer.file_settings`.</li><li>`:test`: save emails to `ActionMailer::Base.deliveries` array.</li></ul>See [API docs](https://api.rubyonrails.org/classes/ActionMailer/Base.html) for more info.|
|`perform_deliveries`|Determines whether deliveries are actually carried out when the `deliver` method is invoked on the Mail message. By default they are, but this can be turned off to help functional testing. If this value is `false`, `deliveries` array will not be populated even if `delivery_method` is `:test`.|
|`deliveries`|Keeps an array of all the emails sent out through the Action Mailer with delivery_method :test. Most useful for unit and functional testing.|
|`delivery_job`|The job class used with `deliver_later`. Defaults to `ActionMailer::MailDeliveryJob`.|
|`deliver_later_queue_name`|The name of the queue used with `deliver_later`.|
|`default_options`|Allows you to set default values for the `mail` method options (`:from`, `:reply_to`, etc.).|
For a complete writeup of possible configurations see the

@ -80,7 +80,7 @@ Rails supports six types of associations:
* [`has_one :through`][`has_one`]
* [`has_and_belongs_to_many`][]
Associations are implemented using macro-style calls, so that you can declaratively add features to your models. For example, by declaring that one model `belongs_to` another, you instruct Rails to maintain [Primary Key](https://en.wikipedia.org/wiki/Unique_key)-[Foreign Key](https://en.wikipedia.org/wiki/Foreign_key) information between instances of the two models, and you also get a number of utility methods added to your model.
Associations are implemented using macro-style calls, so that you can declaratively add features to your models. For example, by declaring that one model `belongs_to` another, you instruct Rails to maintain [Primary Key](https://en.wikipedia.org/wiki/Primary_key)-[Foreign Key](https://en.wikipedia.org/wiki/Foreign_key) information between instances of the two models, and you also get a number of utility methods added to your model.
In the remainder of this guide, you'll learn how to declare and use the various forms of associations. But first, a quick introduction to the situations where each association type is appropriate.

@ -456,6 +456,27 @@ However, if an engine supports Rails 6 or Rails 6.1 and does not control its par
3. In `classic` mode, a file `app/model/concerns/foo.rb` is allowed to define both `Foo` and `Concerns::Foo`. In `zeitwerk` mode, there's only one option: it has to define `Foo`. In order to be compatible, define `Foo`.
Testing
-------
### Manual Testing
The task `zeitwerk:check` checks if the project tree follows the expected naming conventions and it is handy for manual checks. For example, if you're migrating from `classic` to `zeitwerk` mode, or if you're fixing something:
```
% bin/rails zeitwerk:check
Hold on, I am eager loading the application.
All is good!
```
There can be additional output depending on the application configuration, but the last "All is good!" is what you are looking for.
### Automated Testing
It is a good practice to verify in the test suite that the project eager loads correctly.
That covers Zeitwerk naming compliance and other possible error conditions. Please check the [section about testing eager loading](testing.html#testing-eager-loading) in the [_Testing Rails Applications_](testing.html) guide.
Troubleshooting
---------------

@ -29,9 +29,9 @@ Starting with Rails 6, Rails ships with a new and better way to autoload, which
Why Switch from `classic` to `zeitwerk`?
----------------------------------------
The `classic` autoloader has been extremely useful, but had a number of [issues](https://guides.rubyonrails.org/v6.1/autoloading_and_reloading_constants_classic_mode.html#common-gotchas) that made autoloading a bit tricky and confusing at times. Zeitwerk was developed to address them, among other [motivations](https://github.com/fxn/zeitwerk#motivation).
The `classic` autoloader has been extremely useful, but had a number of [issues](https://guides.rubyonrails.org/v6.1/autoloading_and_reloading_constants_classic_mode.html#common-gotchas) that made autoloading a bit tricky and confusing at times. Zeitwerk was developed to address this, among other [motivations](https://github.com/fxn/zeitwerk#motivation).
When upgrading to Rails 6.x, it is highly encouraged to switch to `zeitwerk` mode because `classic` mode is deprecated.
When upgrading to Rails 6.x, it is highly encouraged to switch to `zeitwerk` mode because it is a better autoloader, `classic` mode is deprecated.
Rails 7 ends the transition period and does not include `classic` mode.
@ -80,7 +80,7 @@ config.autoloader = :zeitwerk
In Rails 7 there is only `zeitwerk` mode, you do not need to do anything to enable it.
Indeed, the setter `config.autoloader=` does not even exist. If `config/application.rb` has it, please just delete the line.
Indeed, in Rails 7 the setter `config.autoloader=` does not even exist. If `config/application.rb` uses it, please delete the line.
How to Verify The Application Runs in `zeitwerk` Mode?
@ -162,6 +162,8 @@ Hold on, I am eager loading the application.
All is good!
```
Once all is good, it is recommended to keep validating the project in the test suite. The section [_Check Zeitwerk Compliance in the Test Suite_](#check-zeitwerk-compliance-in-the-test-suite) explains how to do this.
### Concerns
You can autoload and eager load from a standard structure with `concerns` subdirectories like
@ -306,11 +308,26 @@ Please make sure to depend on at least Bootsnap 1.4.4.
Check Zeitwerk Compliance in the Test Suite
-------------------------------------------
The Rake task `zeitwerk:check` just eager loads, because doing so triggers built-in validations in Zeitwerk.
The task `zeitwerk:check` is handy while migrating. Once the project is compliant, it is recommended to automate this check. In order to do so, it is enough to eager load the application, which is all `zeitwerk:check` does, indeed.
You can add the equivalent of this to your test suite to make sure the application always loads correctly regardless of test coverage:
### Continuous Integration
### minitest
If your project has continuous integration in place, it is a good idea to eager load the application when the suite runs there. If the application cannot be eager loaded for whatever reason, you want to know in CI, better than in production, right?
CIs typically set some environment variable to indicate the test suite is running there. For example, it could be `CI`:
```ruby
# config/environments/test.rb
config.eager_load = ENV["CI"].present?
```
Starting with Rails 7, newly generated applications are configured that way by default.
### Bare Test Suites
If your project does not have continuous integration, you can still eager load in the test suite by calling `Rails.application.eager_load!`:
#### minitest
```ruby
require "test_helper"
@ -322,7 +339,7 @@ class ZeitwerkComplianceTest < ActiveSupport::TestCase
end
```
### RSpec
#### RSpec
```ruby
require "rails_helper"
@ -334,6 +351,21 @@ RSpec.describe "Zeitwerk compliance" do
end
```
Delete any `require` calls
--------------------------
In my experience, projects generally do not do this. But I've seen a couple, and have heard of a few others.
In Rails application you use `require` exclusively to load code from `lib` or from 3rd party like gem dependencies or the standard library. **Never load autoloadable application code with `require`**. See why this was a bad idea already in `classic` [here](https://guides.rubyonrails.org/v6.1/autoloading_and_reloading_constants_classic_mode.html#autoloading-and-require).
```ruby
require "nokogiri" # GOOD
require "net/http" # GOOD
require "user" # BAD, DELETE THIS (assuming app/models/user.rb)
```
Please delete any `require` calls of that type.
New Features You Can Leverage
-----------------------------
@ -343,13 +375,12 @@ All known use cases of `require_dependency` have been eliminated with Zeitwerk.
If your application uses Single Table Inheritance, please see the [Single Table Inheritance section](autoloading_and_reloading_constants.html#single-table-inheritance) of the Autoloading and Reloading Constants (Zeitwerk Mode) guide.
### Qualified Names in Class and Module Definitions Are Now Possible
You can now robustly use constant paths in class and module definitions:
```ruby
# Autoloading in this class' body matches Ruby semantics now.
# Autoloading in this class body matches Ruby semantics now.
class Admin::UsersController < ApplicationController
# ...
end
@ -383,7 +414,7 @@ end
### Thread-safety Everywhere
In classic mode, constant autoloading is not thread-safe, though Rails has locks in place for example to make web requests thread-safe.
In `classic` mode, constant autoloading is not thread-safe, though Rails has locks in place for example to make web requests thread-safe.
Constant autoloading is thread-safe in `zeitwerk` mode. For example, you can now autoload in multi-threaded scripts executed by the `runner` command.

@ -1405,6 +1405,13 @@ If set to `false`:
The default value is `true` for new apps. Upgraded apps will have that value set to `false` for backwards-compatibility.
#### `config.active_support.executor_around_test_case`
Configure the test suite to call `Rails.application.executor.wrap` around test cases.
This makes test cases behave closer to an actual request or job.
Several features that are normally disabled in test, such as Active Record query cache
and asynchronous queries will then be enabled.
#### `ActiveSupport::Logger.silencer`
Is set to `false` to disable the ability to silence logging in a block. The default is `true`.
@ -1721,6 +1728,8 @@ Accepts a string for the HTML tag used to wrap attachments. Defaults to `"action
- `config.active_support.hash_digest_class`: `OpenSSL::Digest::SHA256`
- `config.active_support.cache_format_version`: `7.0`
- `config.active_support.remove_deprecated_time_with_zone_name`: `true`
- `config.active_support.executor_around_test_case`: `true`
- `config.active_support.use_rfc4122_namespaced_uuids`: `true`
- `config.action_dispatch.return_only_request_media_type_on_content_type`: `false`
- `config.action_controller.silence_disabled_session_errors`: `false`
- `config.action_mailer.smtp_timeout`: `5`
@ -1807,6 +1816,8 @@ Accepts a string for the HTML tag used to wrap attachments. Defaults to `"action
- `config.active_support.hash_digest_class`: `OpenSSL::Digest::MD5`
- `config.active_support.key_generator_hash_digest_class`: `OpenSSL::Digest::SHA1`
- `config.active_support.cache_format_version`: `6.1`
- `config.active_support.executor_around_test_case`: `false`
- ``config.active_support.use_rfc4122_namespaced_uuids``: `false`
- `config.action_dispatch.return_only_request_media_type_on_content_type`: `true`
- `ActiveSupport.utc_to_local_returns_utc_offset_times`: `false`
- `config.action_mailer.smtp_timeout`: `nil`

@ -1766,7 +1766,7 @@ and in `app/controllers/comments_controller.rb`:
end
```
Within the `article` model, after running a migration to add a `status` column, you would add:
Within the `article` model, after running a migration to add a `status` column using `bin/rails db:migrate` command, you would add:
```ruby
class Article < ApplicationRecord

@ -1972,6 +1972,54 @@ class ChatRelayJobTest < ActiveJob::TestCase
end
```
Testing Eager Loading
---------------------
Normally, applications do not eager load in the `development` or `test` environments to speed things up. But they do in the `production` environment.
If some file in the project cannot be loaded for whatever reason, you better detect it before deploying to production, right?
### Continuous Integration
If your project has CI in place, eager loading in CI is an easy way to ensure the application eager loads.
CIs typically set some environment variable to indicate the test suite is running there. For example, it could be `CI`:
```ruby
# config/environments/test.rb
config.eager_load = ENV["CI"].present?
```
Starting with Rails 7, newly generated applications are configured that way by default.
### Bare Test Suites
If your project does not have continuous integration, you can still eager load in the test suite by calling `Rails.application.eager_load!`:
#### minitest
```ruby
require "test_helper"
class ZeitwerkComplianceTest < ActiveSupport::TestCase
test "eager loads all files without errors" do
assert_nothing_raised { Rails.application.eager_load! }
end
end
```
#### RSpec
```ruby
require "rails_helper"
RSpec.describe "Zeitwerk compliance" do
it "eager loads all files without errors" do
expect { Rails.application.eager_load! }.not_to raise_error
end
end
```
Additional Testing Resources
----------------------------

@ -44,7 +44,7 @@ def paths
exclude: ["assets", javascript_path]
paths.add "app/assets", glob: "*"
paths.add "app/controllers", eager_load: true
paths.add "app/channels", eager_load: true, glob: "**/*_channel.rb"
paths.add "app/channels", eager_load: true
paths.add "app/helpers", eager_load: true
paths.add "app/models", eager_load: true
paths.add "app/mailers", eager_load: true

@ -313,7 +313,7 @@ def jbuilder_gemfile_entry
def javascript_gemfile_entry
return [] if options[:skip_javascript]
if options[:javascript] == "importmap"
if adjusted_javascript_option == "importmap"
GemfileEntry.version("importmap-rails", ">= 0.3.4", "Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]")
else
GemfileEntry.version "jsbundling-rails", "~> 0.1.0", "Bundle and transpile JavaScript [https://github.com/rails/jsbundling-rails]"
@ -336,6 +336,16 @@ def using_node?
options[:javascript] && options[:javascript] != "importmap"
end
# CSS processors other than Tailwind require a node-based JavaScript environment. So overwrite the normal JS default
# if one such processor has been specified.
def adjusted_javascript_option
if options[:css] && options[:css] != "tailwind" && options[:javascript] == "importmap"
"esbuild"
else
options[:javascript]
end
end
def css_gemfile_entry
return [] unless options[:css]
@ -407,9 +417,9 @@ def run_bundle
def run_javascript
return if options[:skip_javascript] || !bundle_install?
case options[:javascript]
case adjusted_javascript_option
when "importmap" then rails_command "importmap:install"
when "webpack", "esbuild", "rollup" then rails_command "javascript:install:#{options[:javascript]}"
when "webpack", "esbuild", "rollup" then rails_command "javascript:install:#{adjusted_javascript_option}"
end
end

@ -1,6 +1,6 @@
<%%= form_with(model: <%= model_resource_name %>) do |form| %>
<%% if <%= singular_table_name %>.errors.any? %>
<div id="error_explanation">
<div style="color: red">
<h2><%%= pluralize(<%= singular_table_name %>.errors.count, "error") %> prohibited this <%= singular_table_name %> from being saved:</h2>
<ul>
@ -12,26 +12,26 @@
<%% end %>
<% attributes.each do |attribute| -%>
<div class="field">
<div>
<% if attribute.password_digest? -%>
<%%= form.label :password %>
<%%= form.label :password, style: "display: block" %>
<%%= form.password_field :password %>
</div>
<div class="field">
<%%= form.label :password_confirmation %>
<div>
<%%= form.label :password_confirmation, style: "display: block" %>
<%%= form.password_field :password_confirmation %>
<% elsif attribute.attachments? -%>
<%%= form.label :<%= attribute.column_name %> %>
<%%= form.label :<%= attribute.column_name %>, style: "display: block" %>
<%%= form.<%= attribute.field_type %> :<%= attribute.column_name %>, multiple: true %>
<% else -%>
<%%= form.label :<%= attribute.column_name %> %>
<%%= form.label :<%= attribute.column_name %>, style: "display: block" %>
<%%= form.<%= attribute.field_type %> :<%= attribute.column_name %> %>
<% end -%>
</div>
<% end -%>
<div class="actions">
<div>
<%%= form.submit %>
</div>
<%% end %>

@ -1,4 +1,4 @@
<p id="notice"><%%= notice %></p>
<p style="color: green"><%%= notice %></p>
<h1><%= human_name.pluralize %></h1>

@ -1,4 +1,4 @@
<div id="<%%= dom_id <%= singular_table_name %> %>" class="scaffold_record">
<div id="<%%= dom_id <%= singular_table_name %> %>">
<% attributes.reject(&:password_digest?).each do |attribute| -%>
<p>
<strong><%= attribute.human_name %>:</strong>

@ -1,4 +1,4 @@
<p id="notice"><%%= notice %></p>
<p style="color: green"><%%= notice %></p>
<%%= render @<%= singular_table_name %> %>

@ -46,8 +46,8 @@
# Rails.application.config.active_support.cache_format_version = 7.0
# Calls `Rails.application.executor.wrap` around test cases.
# This makes test cases behave closer to an actual request or job, several
# features that are normally disabled in test, such as Active Record query cache
# This makes test cases behave closer to an actual request or job.
# Several features that are normally disabled in test, such as Active Record query cache
# and asynchronous queries will then be enabled.
# Rails.application.config.active_support.executor_around_test_case = true
@ -97,3 +97,10 @@
# Previously this was set in an initializer. It's fine to keep using that initializer if you've customized it.
# To disable parameter wrapping entirely, set this config to `false`.
# Rails.application.config.action_controller.wrap_parameters_by_default = true
# Specifies whether generated namespaced UUIDs follow the RFC 4122 standard for namespace IDs provided as a
# `String` to `Digest::UUID.uuid_v3` or `Digest::UUID.uuid_v5` method calls.
#
# See https://guides.rubyonrails.org/configuring.html#config-active-support-use-rfc4122-namespaced-uuids for
# more information
# Rails.application.config.active_support.use_rfc4122_namespaced_uuids = true

@ -124,7 +124,7 @@
</header>
<% if @part && @part.mime_type %>
<iframe seamless name="messageBody" src="?<%= part_query(@part.mime_type) %>"></iframe>
<iframe name="messageBody" src="?<%= part_query(@part.mime_type) %>"></iframe>
<% else %>
<p>
You are trying to preview an email that does not have any content.

@ -2422,18 +2422,18 @@ def index
assert_equal 1234, ActiveSupport.test_parallelization_threshold
end
test "ActiveSupport.use_rfc4122_namespaced_uuids is enabled by default for new apps" do
test "Digest::UUID.use_rfc4122_namespaced_uuids is enabled by default for new apps" do
app "development"
assert_equal true, ActiveSupport.use_rfc4122_namespaced_uuids
assert_equal true, Digest::UUID.use_rfc4122_namespaced_uuids
end
test "ActiveSupport.use_rfc4122_namespaced_uuids is disabled by default for upgraded apps" do
test "Digest::UUID.use_rfc4122_namespaced_uuids is disabled by default for upgraded apps" do
remove_from_config '.*config\.load_defaults.*\n'
app "development"
assert_equal false, ActiveSupport.use_rfc4122_namespaced_uuids
assert_equal false, Digest::UUID.use_rfc4122_namespaced_uuids
end
test "custom serializers should be able to set via config.active_job.custom_serializers in an initializer" do

@ -585,7 +585,7 @@ def foo
get "/rails/mailers/notifier/foo.txt"
assert_equal 200, last_response.status
assert_match '<iframe seamless name="messageBody" src="?part=text%2Fplain">', last_response.body
assert_match '<iframe name="messageBody" src="?part=text%2Fplain">', last_response.body
assert_match '<option selected value="part=text%2Fplain">', last_response.body
assert_match '<option value="part=text%2Fhtml">', last_response.body
@ -595,7 +595,7 @@ def foo
get "/rails/mailers/notifier/foo.html?name=Ruby"
assert_equal 200, last_response.status
assert_match '<iframe seamless name="messageBody" src="?name=Ruby&amp;part=text%2Fhtml">', last_response.body
assert_match '<iframe name="messageBody" src="?name=Ruby&amp;part=text%2Fhtml">', last_response.body
assert_match '<option selected value="name=Ruby&amp;part=text%2Fhtml">', last_response.body
assert_match '<option value="name=Ruby&amp;part=text%2Fplain">', last_response.body
@ -634,7 +634,7 @@ def foo
get "/rails/mailers/notifier/foo"
assert_equal 200, last_response.status
assert_match %r[<iframe seamless name="messageBody"], last_response.body
assert_match %r[<iframe name="messageBody"], last_response.body
get "/rails/mailers/notifier/foo?part=text/plain"
assert_equal 200, last_response.status
@ -675,7 +675,7 @@ def foo
get "/rails/mailers/notifier/foo"
assert_equal 200, last_response.status
assert_match %r[<iframe seamless name="messageBody"], last_response.body
assert_match %r[<iframe name="messageBody"], last_response.body
get "/rails/mailers/notifier/foo?part=text/plain"
assert_equal 200, last_response.status
@ -721,7 +721,7 @@ def foo
get "/rails/mailers/notifier/foo"
assert_equal 200, last_response.status
assert_match %r[<iframe seamless name="messageBody"], last_response.body
assert_match %r[<iframe name="messageBody"], last_response.body
get "/rails/mailers/notifier/foo?part=text/plain"
assert_equal 200, last_response.status
@ -779,7 +779,7 @@ def foo
get "/rails/mailers/notifier/foo"
assert_equal 200, last_response.status
assert_match %r[<iframe seamless name="messageBody"], last_response.body
assert_match %r[<iframe name="messageBody"], last_response.body
get "/rails/mailers/notifier/foo?part=text/plain"
assert_equal 200, last_response.status