diff options
90 files changed, 1085 insertions, 254 deletions
@@ -44,7 +44,7 @@ gem "libxml-ruby", platforms: :ruby gem "connection_pool", require: false # for railties app_generator_test -gem "bootsnap", ">= 1.1.0", require: false +gem "bootsnap", ">= 1.4.0", require: false # Active Job group :job do diff --git a/Gemfile.lock b/Gemfile.lock index b05ae7915e..b89c64d34e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -70,6 +70,7 @@ PATH i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + zeitwerk (~> 1.0) rails (6.0.0.beta1) actioncable (= 6.0.0.beta1) actionmailbox (= 6.0.0.beta1) @@ -166,9 +167,9 @@ GEM childprocess faraday selenium-webdriver - bootsnap (1.3.2) + bootsnap (1.4.0) msgpack (~> 1.0) - bootsnap (1.3.2-java) + bootsnap (1.4.0-java) msgpack (~> 1.0) builder (3.2.3) bunny (2.13.0) @@ -276,7 +277,7 @@ GEM hiredis (0.6.3-java) http_parser.rb (0.6.0) httpclient (2.8.3) - i18n (1.5.2) + i18n (1.5.3) concurrent-ruby (~> 1.0) image_processing (1.7.1) mini_magick (~> 4.0) @@ -324,10 +325,10 @@ GEM minitest-server (1.0.5) minitest (~> 5.0) mono_logger (1.1.0) - msgpack (1.2.4) - msgpack (1.2.4-java) - msgpack (1.2.4-x64-mingw32) - msgpack (1.2.4-x86-mingw32) + msgpack (1.2.6) + msgpack (1.2.6-java) + msgpack (1.2.6-x64-mingw32) + msgpack (1.2.6-x86-mingw32) multi_json (1.13.1) multipart-post (2.0.0) mustache (1.1.0) @@ -516,6 +517,7 @@ GEM websocket-extensions (0.1.3) xpath (3.2.0) nokogiri (~> 1.8) + zeitwerk (1.0.0) PLATFORMS java @@ -535,7 +537,7 @@ DEPENDENCIES benchmark-ips blade blade-sauce_labs_plugin - bootsnap (>= 1.1.0) + bootsnap (>= 1.4.0) byebug capybara (>= 2.15) chromedriver-helper @@ -590,4 +592,4 @@ DEPENDENCIES websocket-client-simple! BUNDLED WITH - 1.17.2 + 1.17.3 diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index 42edbd6954..3ce35edbb8 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,3 +1,26 @@ +* Allow passing custom configuration to `ActionCable::Server::Base`. + + You can now create a standalone Action Cable server with a custom configuration + (e.g. to run it in isolation from the default one): + + ```ruby + config = ActionCable::Server::Configuration.new + config.cable = { adapter: "redis", channel_prefix: "custom_" } + + CUSTOM_CABLE = ActionCable::Server::Base.new(config: config) + ``` + + Then you can mount it in the `routes.rb` file: + + ```ruby + Rails.application.routes.draw do + mount CUSTOM_CABLE => "/custom_cable" + # ... + end + ``` + + *Vladimir Dementyev* + * Add `:action_cable_connection` and `:action_cable_channel` load hooks. You can use them to extend `ActionCable::Connection::Base` and `ActionCable::Channel::Base` diff --git a/actioncable/lib/action_cable/server/base.rb b/actioncable/lib/action_cable/server/base.rb index 2b9e1cba3b..98b3743175 100644 --- a/actioncable/lib/action_cable/server/base.rb +++ b/actioncable/lib/action_cable/server/base.rb @@ -12,14 +12,17 @@ module ActionCable include ActionCable::Server::Broadcasting include ActionCable::Server::Connections - cattr_accessor :config, instance_accessor: true, default: ActionCable::Server::Configuration.new + cattr_accessor :config, instance_accessor: false, default: ActionCable::Server::Configuration.new + + attr_reader :config def self.logger; config.logger; end delegate :logger, to: :config attr_reader :mutex - def initialize + def initialize(config: self.class.config) + @config = config @mutex = Monitor.new @remote_connections = @event_loop = @worker_pool = @pubsub = nil end diff --git a/actioncable/test/subscription_adapter/channel_prefix.rb b/actioncable/test/subscription_adapter/channel_prefix.rb index 3071facd9d..475e6cfd3a 100644 --- a/actioncable/test/subscription_adapter/channel_prefix.rb +++ b/actioncable/test/subscription_adapter/channel_prefix.rb @@ -2,17 +2,9 @@ require "test_helper" -class ActionCable::Server::WithIndependentConfig < ActionCable::Server::Base - # ActionCable::Server::Base defines config as a class variable. - # Need config to be an instance variable here as we're testing 2 separate configs - def config - @config ||= ActionCable::Server::Configuration.new - end -end - module ChannelPrefixTest def test_channel_prefix - server2 = ActionCable::Server::WithIndependentConfig.new + server2 = ActionCable::Server::Base.new(config: ActionCable::Server::Configuration.new) server2.config.cable = alt_cable_config server2.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb index 5610212fad..033a7f5b9e 100644 --- a/actionmailer/lib/action_mailer/base.rb +++ b/actionmailer/lib/action_mailer/base.rb @@ -593,6 +593,7 @@ module ActionMailer private def set_payload_for_mail(payload, mail) + payload[:mail] = mail.encoded payload[:mailer] = name payload[:message_id] = mail.message_id payload[:subject] = mail.subject @@ -601,7 +602,6 @@ module ActionMailer payload[:bcc] = mail.bcc if mail.bcc.present? payload[:cc] = mail.cc if mail.cc.present? payload[:date] = mail.date - payload[:mail] = mail.encoded payload[:perform_deliveries] = mail.perform_deliveries end diff --git a/actionmailer/lib/action_mailer/log_subscriber.rb b/actionmailer/lib/action_mailer/log_subscriber.rb index 25c99342c2..26910f20f0 100644 --- a/actionmailer/lib/action_mailer/log_subscriber.rb +++ b/actionmailer/lib/action_mailer/log_subscriber.rb @@ -10,11 +10,10 @@ module ActionMailer def deliver(event) info do perform_deliveries = event.payload[:perform_deliveries] - recipients = Array(event.payload[:to]).join(", ") if perform_deliveries - "Sent mail to #{recipients} (#{event.duration.round(1)}ms)" + "Delivered mail #{event.payload[:message_id]} (#{event.duration.round(1)}ms)" else - "Skipped sending mail to #{recipients} as `perform_deliveries` is false" + "Skipped delivery of mail #{event.payload[:message_id]} as `perform_deliveries` is false" end end diff --git a/actionmailer/lib/action_mailer/railtie.rb b/actionmailer/lib/action_mailer/railtie.rb index 23488db790..893a4a25b1 100644 --- a/actionmailer/lib/action_mailer/railtie.rb +++ b/actionmailer/lib/action_mailer/railtie.rb @@ -59,6 +59,14 @@ module ActionMailer end end + initializer "action_mailer.set_autoload_paths" do |app| + options = app.config.action_mailer + + if options.show_previews && options.preview_path + ActiveSupport::Dependencies.autoload_paths << options.preview_path + end + end + initializer "action_mailer.compile_config_methods" do ActiveSupport.on_load(:action_mailer) do config.compile_methods! if config.respond_to?(:compile_methods!) @@ -76,12 +84,8 @@ module ActionMailer if options.show_previews app.routes.prepend do - get "/rails/mailers" => "rails/mailers#index", internal: true - get "/rails/mailers/*path" => "rails/mailers#preview", internal: true - end - - if options.preview_path - ActiveSupport::Dependencies.autoload_paths << options.preview_path + get "/rails/mailers" => "rails/mailers#index", internal: true + get "/rails/mailers/*path" => "rails/mailers#preview", internal: true end end end diff --git a/actionmailer/test/base_test.rb b/actionmailer/test/base_test.rb index d0c4f189fd..15613d4dce 100644 --- a/actionmailer/test/base_test.rb +++ b/actionmailer/test/base_test.rb @@ -913,6 +913,23 @@ class BaseTest < ActiveSupport::TestCase ActiveSupport::Notifications.unsubscribe "process.action_mailer" end + test "notification for deliver" do + begin + events = [] + ActiveSupport::Notifications.subscribe("deliver.action_mailer") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + BaseMailer.welcome(body: "Hello there").deliver_now + + assert_equal 1, events.length + assert_equal "deliver.action_mailer", events[0].name + assert_not_nil events[0].payload[:message_id] + ensure + ActiveSupport::Notifications.unsubscribe "deliver.action_mailer" + end + end + private # Execute the block setting the given values and restoring old values after diff --git a/actionmailer/test/log_subscriber_test.rb b/actionmailer/test/log_subscriber_test.rb index fb569ce45f..f09f1997c0 100644 --- a/actionmailer/test/log_subscriber_test.rb +++ b/actionmailer/test/log_subscriber_test.rb @@ -24,11 +24,11 @@ class AMLogSubscriberTest < ActionMailer::TestCase end def test_deliver_is_notified - BaseMailer.welcome.deliver_now + BaseMailer.welcome(message_id: "123@abc").deliver_now wait assert_equal(1, @logger.logged(:info).size) - assert_match(/Sent mail to system@test\.lindsaar\.net/, @logger.logged(:info).first) + assert_match(/Delivered mail 123@abc/, @logger.logged(:info).first) assert_equal(2, @logger.logged(:debug).size) assert_match(/BaseMailer#welcome: processed outbound mail in [\d.]+ms/, @logger.logged(:debug).first) @@ -38,11 +38,11 @@ class AMLogSubscriberTest < ActionMailer::TestCase end def test_deliver_message_when_perform_deliveries_is_false - BaseMailer.welcome_without_deliveries.deliver_now + BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now wait assert_equal(1, @logger.logged(:info).size) - assert_match("Skipped sending mail to system@test.lindsaar.net as `perform_deliveries` is false", @logger.logged(:info).first) + assert_match("Skipped delivery of mail 123@abc as `perform_deliveries` is false", @logger.logged(:info).first) assert_equal(2, @logger.logged(:debug).size) assert_match(/BaseMailer#welcome_without_deliveries: processed outbound mail in [\d.]+ms/, @logger.logged(:debug).first) diff --git a/actionmailer/test/mailers/base_mailer.rb b/actionmailer/test/mailers/base_mailer.rb index c1bb48cc96..dbe1c4f0e6 100644 --- a/actionmailer/test/mailers/base_mailer.rb +++ b/actionmailer/test/mailers/base_mailer.rb @@ -21,8 +21,8 @@ class BaseMailer < ActionMailer::Base mail(template_name: "welcome", template_path: path) end - def welcome_without_deliveries - mail(template_name: "welcome") + def welcome_without_deliveries(hash = {}) + mail({ template_name: "welcome" }.merge!(hash)) mail.perform_deliveries = false end diff --git a/actionpack/lib/action_dispatch/middleware/debug_view.rb b/actionpack/lib/action_dispatch/middleware/debug_view.rb index f16484d1ea..43c0a84504 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_view.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_view.rb @@ -11,8 +11,8 @@ module ActionDispatch def initialize(assigns) paths = [RESCUES_TEMPLATE_PATH] - renderer = ActionView::Renderer.new ActionView::LookupContext.new(paths) - super(renderer, assigns) + lookup_context = ActionView::LookupContext.new(paths) + super(lookup_context, assigns) end def compiled_method_container diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index 45439a3bb1..10d85037ae 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -335,7 +335,7 @@ module ActionDispatch klass = APP_SESSIONS[app] ||= Class.new(Integration::Session) { # If the app is a Rails app, make url_helpers available on the session. # This makes app.url_for and app.foo_path available in the console. - if app.respond_to?(:routes) + if app.respond_to?(:routes) && app.routes.is_a?(ActionDispatch::Routing::RouteSet) include app.routes.url_helpers include app.routes.mounted_helpers end diff --git a/actionpack/test/dispatch/routing/non_dispatch_routed_app_test.rb b/actionpack/test/dispatch/routing/non_dispatch_routed_app_test.rb new file mode 100644 index 0000000000..676a8c38d4 --- /dev/null +++ b/actionpack/test/dispatch/routing/non_dispatch_routed_app_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module ActionDispatch + module Routing + class NonDispatchRoutedAppTest < ActionDispatch::IntegrationTest + # For example, Grape::API + class SimpleApp + def self.call(env) + [ 200, { "Content-Type" => "text/plain" }, [] ] + end + + def self.routes + [] + end + end + + setup { @app = SimpleApp } + + test "does not except" do + get "/foo" + assert_response :success + end + end + end +end diff --git a/actionview/lib/action_view/base.rb b/actionview/lib/action_view/base.rb index 420136d6de..712e5d251e 100644 --- a/actionview/lib/action_view/base.rb +++ b/actionview/lib/action_view/base.rb @@ -196,10 +196,9 @@ module ActionView #:nodoc: end end - attr_reader :view_renderer + attr_reader :view_renderer, :lookup_context attr_internal :config, :assigns - delegate :lookup_context, to: :view_renderer delegate :formats, :formats=, :locale, :locale=, :view_paths, :view_paths=, to: :lookup_context def assign(new_assigns) # :nodoc: @@ -208,12 +207,17 @@ module ActionView #:nodoc: # :stopdoc: - def self.build_renderer(context, controller, formats) - lookup_context = context.is_a?(ActionView::LookupContext) ? - context : ActionView::LookupContext.new(context) - lookup_context.formats = formats if formats - lookup_context.prefixes = controller._prefixes if controller - ActionView::Renderer.new(lookup_context) + def self.build_lookup_context(context) + case context + when ActionView::Renderer + context.lookup_context + when Array + ActionView::LookupContext.new(context) + when nil + ActionView::LookupContext.new([]) + else + raise NotImplementedError, context.class.name + end end def self.empty @@ -225,34 +229,35 @@ module ActionView #:nodoc: end def self.with_context(context, assigns = {}, controller = nil) - new ActionView::Renderer.new(context), assigns, controller + new context, assigns, controller end NULL = Object.new # :startdoc: - def initialize(renderer = nil, assigns = {}, controller = nil, formats = NULL) #:nodoc: + def initialize(lookup_context = nil, assigns = {}, controller = nil, formats = NULL) #:nodoc: @_config = ActiveSupport::InheritableOptions.new - if formats == NULL - formats = nil - else + unless formats == NULL ActiveSupport::Deprecation.warn <<~eowarn Passing formats to ActionView::Base.new is deprecated eowarn end - if renderer.is_a?(ActionView::Renderer) - @view_renderer = renderer + case lookup_context + when ActionView::LookupContext + @lookup_context = lookup_context else ActiveSupport::Deprecation.warn <<~eowarn - ActionView::Base instances should be constructed with a view renderer, + ActionView::Base instances should be constructed with a lookup context, assigments, and a controller. eowarn - @view_renderer = self.class.build_renderer(renderer, controller, formats) + @lookup_context = self.class.build_lookup_context(lookup_context) end + @view_renderer = ActionView::Renderer.new @lookup_context + @cache_hit = {} assign(assigns) assign_controller(controller) @@ -275,6 +280,25 @@ module ActionView #:nodoc: msg end + def in_context(options, locals) + old_view_renderer = @view_renderer + old_lookup_context = @lookup_context + + if !lookup_context.html_fallback_for_js && options[:formats] + formats = Array(options[:formats]) + if formats == [:js] + formats << :html + end + @lookup_context = lookup_context.with_prepended_formats(formats) + @view_renderer = ActionView::Renderer.new @lookup_context + end + + yield @view_renderer + ensure + @view_renderer = old_view_renderer + @lookup_context = old_lookup_context + end + ActiveSupport.run_load_hooks(:action_view, self) end end diff --git a/actionview/lib/action_view/helpers/rendering_helper.rb b/actionview/lib/action_view/helpers/rendering_helper.rb index 1e12aa2736..7323963c72 100644 --- a/actionview/lib/action_view/helpers/rendering_helper.rb +++ b/actionview/lib/action_view/helpers/rendering_helper.rb @@ -27,10 +27,12 @@ module ActionView def render(options = {}, locals = {}, &block) case options when Hash - if block_given? - view_renderer.render_partial(self, options.merge(partial: options[:layout]), &block) - else - view_renderer.render(self, options) + in_context(options, locals) do |renderer| + if block_given? + view_renderer.render_partial(self, options.merge(partial: options[:layout]), &block) + else + view_renderer.render(self, options) + end end else view_renderer.render_partial(self, partial: options, locals: locals, &block) diff --git a/actionview/lib/action_view/lookup_context.rb b/actionview/lib/action_view/lookup_context.rb index 61fe11cf45..125ab4dbe3 100644 --- a/actionview/lib/action_view/lookup_context.rb +++ b/actionview/lib/action_view/lookup_context.rb @@ -260,6 +260,13 @@ module ActionView @digest_cache ||= DetailsKey.digest_cache(@details) end + def with_prepended_formats(formats) + details = @details.dup + details[:formats] = formats + + self.class.new(@view_paths, details, @prefixes) + end + def initialize_details(target, details) registered_details.each do |k| target[k] = details[k] || Accessors::DEFAULT_PROCS[k].call diff --git a/actionview/lib/action_view/renderer/partial_renderer.rb b/actionview/lib/action_view/renderer/partial_renderer.rb index 801916954f..f8a6f13ae9 100644 --- a/actionview/lib/action_view/renderer/partial_renderer.rb +++ b/actionview/lib/action_view/renderer/partial_renderer.rb @@ -308,6 +308,9 @@ module ActionView template = find_partial(@path, @template_keys) @variable ||= template.variable else + if options[:cached] + raise NotImplementedError, "render caching requires a template. Please specify a partial when rendering" + end template = nil end @@ -337,9 +340,14 @@ module ActionView spacer = find_template(@options[:spacer_template], @locals.keys).render(view, @locals) end - cache_collection_render(payload, view, template) do - template ? collection_with_template(view, template) : collection_without_template(view) - end.join(spacer).html_safe + collection_body = if template + cache_collection_render(payload, view, template) do + collection_with_template(view, template) + end + else + collection_without_template(view) + end + collection_body.join(spacer).html_safe end end @@ -379,8 +387,6 @@ module ActionView @locals = options[:locals] || {} @details = extract_details(options) - prepend_formats(options[:formats]) - partial = options[:partial] if String === partial diff --git a/actionview/lib/action_view/rendering.rb b/actionview/lib/action_view/rendering.rb index da92ce1f5e..b798e80b04 100644 --- a/actionview/lib/action_view/rendering.rb +++ b/actionview/lib/action_view/rendering.rb @@ -82,7 +82,7 @@ module ActionView # # Override this method in a module to change the default behavior. def view_context - view_context_class.new(view_renderer, view_assigns, self) + view_context_class.new(lookup_context, view_assigns, self) end # Returns an object that is able to render templates. @@ -112,7 +112,7 @@ module ActionView lookup_context.rendered_format = nil if options[:formats] lookup_context.variants = variant if variant - view_renderer.render(context, options) + context.view_renderer.render(context, options) end # Assign the rendered format to look up context. diff --git a/actionview/lib/action_view/template/handlers/erb/erubi.rb b/actionview/lib/action_view/template/handlers/erb/erubi.rb index 15ca202024..20510c3062 100644 --- a/actionview/lib/action_view/template/handlers/erb/erubi.rb +++ b/actionview/lib/action_view/template/handlers/erb/erubi.rb @@ -26,7 +26,7 @@ module ActionView view = Class.new(ActionView::Base) { include action_view_erb_handler_context._routes.url_helpers class_eval("define_method(:_template) { |local_assigns, output_buffer| #{src} }", @filename || "(erubi)", 0) - }.with_context(action_view_erb_handler_context) + }.empty view.run(:_template, {}, ActionView::OutputBuffer.new) end diff --git a/actionview/test/fixtures/test/_first.html.erb b/actionview/test/fixtures/test/_first.html.erb new file mode 100644 index 0000000000..2e2c825acb --- /dev/null +++ b/actionview/test/fixtures/test/_first.html.erb @@ -0,0 +1 @@ +"HTML" diff --git a/actionview/test/fixtures/test/_first.xml.erb b/actionview/test/fixtures/test/_first.xml.erb new file mode 100644 index 0000000000..9cf4f4ec85 --- /dev/null +++ b/actionview/test/fixtures/test/_first.xml.erb @@ -0,0 +1 @@ +"XML" diff --git a/actionview/test/fixtures/test/_first_layer.html.erb b/actionview/test/fixtures/test/_first_layer.html.erb new file mode 100644 index 0000000000..c1f1acb410 --- /dev/null +++ b/actionview/test/fixtures/test/_first_layer.html.erb @@ -0,0 +1,4 @@ +{"format":"HTML", "children": +[ + <%= render(partial: "first").chomp.html_safe %>, +]} diff --git a/actionview/test/fixtures/test/_first_layer.xml.erb b/actionview/test/fixtures/test/_first_layer.xml.erb new file mode 100644 index 0000000000..b8581bbbfc --- /dev/null +++ b/actionview/test/fixtures/test/_first_layer.xml.erb @@ -0,0 +1,4 @@ +{"format":"XML", "children": +[ + <%= render(partial: "first").chomp.html_safe %> +]} diff --git a/actionview/test/fixtures/test/_second.html.erb b/actionview/test/fixtures/test/_second.html.erb new file mode 100644 index 0000000000..2e2c825acb --- /dev/null +++ b/actionview/test/fixtures/test/_second.html.erb @@ -0,0 +1 @@ +"HTML" diff --git a/actionview/test/fixtures/test/_second.xml.erb b/actionview/test/fixtures/test/_second.xml.erb new file mode 100644 index 0000000000..9cf4f4ec85 --- /dev/null +++ b/actionview/test/fixtures/test/_second.xml.erb @@ -0,0 +1 @@ +"XML" diff --git a/actionview/test/fixtures/test/_second_layer.html.erb b/actionview/test/fixtures/test/_second_layer.html.erb new file mode 100644 index 0000000000..307706abd2 --- /dev/null +++ b/actionview/test/fixtures/test/_second_layer.html.erb @@ -0,0 +1,4 @@ +{"format":"HTML", "children": +[ + <%= render(partial: "first").chomp.html_safe %> +]} diff --git a/actionview/test/fixtures/test/_second_layer.xml.erb b/actionview/test/fixtures/test/_second_layer.xml.erb new file mode 100644 index 0000000000..b8581bbbfc --- /dev/null +++ b/actionview/test/fixtures/test/_second_layer.xml.erb @@ -0,0 +1,4 @@ +{"format":"XML", "children": +[ + <%= render(partial: "first").chomp.html_safe %> +]} diff --git a/actionview/test/fixtures/test/mixing_formats.html.erb b/actionview/test/fixtures/test/mixing_formats.html.erb new file mode 100644 index 0000000000..c65cdd7dd4 --- /dev/null +++ b/actionview/test/fixtures/test/mixing_formats.html.erb @@ -0,0 +1,5 @@ +{"format":"HTML", "children": +[ + <%= render(partial: "first", formats: :xml).chomp.html_safe %>, + <%= render(partial: "second").chomp.html_safe %> +]} diff --git a/actionview/test/fixtures/test/mixing_formats_deep.html.erb b/actionview/test/fixtures/test/mixing_formats_deep.html.erb new file mode 100644 index 0000000000..e328887eeb --- /dev/null +++ b/actionview/test/fixtures/test/mixing_formats_deep.html.erb @@ -0,0 +1,5 @@ +{"format":"HTML", "children": +[ + <%= render(partial: "first_layer", formats: :xml).chomp.html_safe %>, + <%= render(partial: "second_layer").chomp.html_safe %> +]} diff --git a/actionview/test/template/csp_helper_test.rb b/actionview/test/template/csp_helper_test.rb new file mode 100644 index 0000000000..8bad25ba7d --- /dev/null +++ b/actionview/test/template/csp_helper_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "abstract_unit" + +class CspHelperWithCspEnabledTest < ActionView::TestCase + tests ActionView::Helpers::CspHelper + + def content_security_policy_nonce + "iyhD0Yc0W+c=" + end + + def content_security_policy? + true + end + + def test_csp_meta_tag + assert_equal "<meta name=\"csp-nonce\" content=\"iyhD0Yc0W+c=\" />", csp_meta_tag + end +end + +class CspHelperWithCspDisabledTest < ActionView::TestCase + tests ActionView::Helpers::CspHelper + + def content_security_policy? + false + end + + def test_csp_meta_tag + assert_nil csp_meta_tag + end +end diff --git a/actionview/test/template/log_subscriber_test.rb b/actionview/test/template/log_subscriber_test.rb index 83bb651ea3..85735139c1 100644 --- a/actionview/test/template/log_subscriber_test.rb +++ b/actionview/test/template/log_subscriber_test.rb @@ -16,8 +16,7 @@ class AVLogSubscriberTest < ActiveSupport::TestCase view_paths = ActionController::Base.view_paths lookup_context = ActionView::LookupContext.new(view_paths, {}, ["test"]) - renderer = ActionView::Renderer.new(lookup_context) - @view = ActionView::Base.with_empty_template_cache.new(renderer, {}) + @view = ActionView::Base.with_empty_template_cache.new(lookup_context, {}) ActionView::LogSubscriber.attach_to :action_view diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb index 3f298d81f3..cda8c942d8 100644 --- a/actionview/test/template/render_test.rb +++ b/actionview/test/template/render_test.rb @@ -20,7 +20,10 @@ module RenderTestCases controller = TestController.new - @controller_view = controller.view_context_class.with_empty_template_cache.new(controller.view_renderer, controller.view_assigns, controller) + @controller_view = controller.view_context_class.with_empty_template_cache.new( + controller.lookup_context, + controller.view_assigns, + controller) # Reload and register danish language for testing I18n.backend.store_translations "da", {} @@ -30,6 +33,21 @@ module RenderTestCases assert_equal ORIGINAL_LOCALES, I18n.available_locales.map(&:to_s).sort end + def test_implicit_format_comes_from_parent_template + rendered_templates = JSON.parse(@controller_view.render(template: "test/mixing_formats")) + assert_equal({ "format" => "HTML", + "children" => ["XML", "HTML"] }, rendered_templates) + end + + def test_implicit_format_comes_from_parent_template_cascading + rendered_templates = JSON.parse(@controller_view.render(template: "test/mixing_formats_deep")) + assert_equal({ "format" => "HTML", + "children" => [ + { "format" => "XML", "children" => ["XML"] }, + { "format" => "HTML", "children" => ["HTML"] }, + ] }, rendered_templates) + end + def test_render_without_options e = assert_raises(ArgumentError) { @view.render() } assert_match(/You invoked render but did not give any of (.+) option\./, e.message) @@ -68,7 +86,7 @@ module RenderTestCases def test_render_partial_use_last_prepended_format_for_partials_with_the_same_names @view.lookup_context.formats = [:html] - assert_equal "\nHTML Template, but JSON partial", @view.render(template: "test/change_priority") + assert_equal "\nHTML Template, but HTML partial", @view.render(template: "test/change_priority") end def test_render_template_with_a_missing_partial_of_another_format @@ -752,6 +770,17 @@ class CachedCollectionViewRenderTest < ActiveSupport::TestCase @view.render(partial: "test/cached_customer", collection: [customer], cached: true) end + test "collection caching does not work on multi-partials" do + a = Object.new + b = Object.new + def a.to_partial_path; "test/partial_iteration_1"; end + def b.to_partial_path; "test/partial_iteration_2"; end + + assert_raises(NotImplementedError) do + @controller_view.render(partial: [a, b], cached: true) + end + end + private def cache_key(*names, virtual_path) digest = ActionView::Digestor.digest name: virtual_path, finder: @view.lookup_context, dependencies: [] diff --git a/activejob/lib/active_job/core.rb b/activejob/lib/active_job/core.rb index 487cdd6d38..2ce008e3da 100644 --- a/activejob/lib/active_job/core.rb +++ b/activejob/lib/active_job/core.rb @@ -40,6 +40,9 @@ module ActiveJob # Timezone to be used during the job. attr_accessor :timezone + # Track when a job was enqueded + attr_accessor :enqueued_at + # These methods will be included into any Active Job object, adding # helpers for de/serialization and creation of job instances. module ClassMethods @@ -97,7 +100,8 @@ module ActiveJob "executions" => executions, "exception_executions" => exception_executions, "locale" => I18n.locale.to_s, - "timezone" => Time.zone.try(:name) + "timezone" => Time.zone.try(:name), + "enqueued_at" => Time.now.utc.iso8601 } end @@ -137,6 +141,7 @@ module ActiveJob self.exception_executions = job_data["exception_executions"] self.locale = job_data["locale"] || I18n.locale.to_s self.timezone = job_data["timezone"] || Time.zone.try(:name) + self.enqueued_at = job_data["enqueued_at"] end private diff --git a/activejob/lib/active_job/logging.rb b/activejob/lib/active_job/logging.rb index 416be83c24..1134e718a8 100644 --- a/activejob/lib/active_job/logging.rb +++ b/activejob/lib/active_job/logging.rb @@ -70,7 +70,7 @@ module ActiveJob def perform_start(event) info do job = event.payload[:job] - "Performing #{job.class.name} (Job ID: #{job.job_id}) from #{queue_name(event)}" + args_info(job) + "Performing #{job.class.name} (Job ID: #{job.job_id}) from #{queue_name(event)} enqueued at #{job.enqueued_at}" + args_info(job) end end diff --git a/activejob/lib/active_job/queue_name.rb b/activejob/lib/active_job/queue_name.rb index 7bb1e35181..de259261de 100644 --- a/activejob/lib/active_job/queue_name.rb +++ b/activejob/lib/active_job/queue_name.rb @@ -18,6 +18,26 @@ module ActiveJob # post.to_feed! # end # end + # + # Can be given a block that will evaluate in the context of the job + # allowing +self.arguments+ to be accessed so that a dynamic queue name + # can be applied: + # + # class PublishToFeedJob < ApplicationJob + # queue_as do + # post = self.arguments.first + # + # if post.paid? + # :paid_feeds + # else + # :feeds + # end + # end + # + # def perform(post) + # post.to_feed! + # end + # end def queue_as(part_name = nil, &block) if block_given? self.queue_name = block diff --git a/activejob/test/cases/job_serialization_test.rb b/activejob/test/cases/job_serialization_test.rb index 86f3651564..c1cec1f1d6 100644 --- a/activejob/test/cases/job_serialization_test.rb +++ b/activejob/test/cases/job_serialization_test.rb @@ -61,4 +61,15 @@ class JobSerializationTest < ActiveSupport::TestCase assert_equal "Hawaii", job.serialize["timezone"] end end + + test "serialize stores the enqueued_at time" do + h1 = HelloJob.new + type = h1.serialize["enqueued_at"].class + assert_equal String, type + + h2 = HelloJob.deserialize(h1.serialize) + # We should be able to parse a timestamp + type = Time.parse(h2.enqueued_at).class + assert_equal Time, type + end end diff --git a/activejob/test/cases/logging_test.rb b/activejob/test/cases/logging_test.rb index 6154ba301d..acd37456c9 100644 --- a/activejob/test/cases/logging_test.rb +++ b/activejob/test/cases/logging_test.rb @@ -115,6 +115,8 @@ class LoggingTest < ActiveSupport::TestCase perform_enqueued_jobs do LoggingJob.perform_later "Dummy" assert_match(/Performing LoggingJob \(Job ID: .*?\) from .*? with arguments:.*Dummy/, @logger.messages) + + assert_match(/enqueued at /, @logger.messages) assert_match(/Dummy, here is it: Dummy/, @logger.messages) assert_match(/Performed LoggingJob \(Job ID: .*?\) from .*? in .*ms/, @logger.messages) end diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index f16a746b91..7b4f3c617c 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -4,12 +4,6 @@ *Ryuta Kamizono* -* Chaining named scope is no longer leaking to class level querying methods. - - Fixes #14003. - - *Ryuta Kamizono* - * Allow applications to automatically switch connections. Adds a middleware and configuration options that can be used in your diff --git a/activerecord/Rakefile b/activerecord/Rakefile index 9824787658..90921dec8b 100644 --- a/activerecord/Rakefile +++ b/activerecord/Rakefile @@ -65,11 +65,42 @@ end task adapter => "#{adapter}:env" do adapter_short = adapter == "db2" ? adapter : adapter[/^[a-z0-9]+/] puts [adapter, adapter_short].inspect - (Dir["test/cases/**/*_test.rb"].reject { + + failing_files = [] + + test_files = (Dir["test/cases/**/*_test.rb"].reject { |x| x.include?("/adapters/") - } + Dir["test/cases/adapters/#{adapter_short}/**/*_test.rb"]).all? do |file| - sh(Gem.ruby, "-w", "-Itest", file) - end || raise("Failures") + } + Dir["test/cases/adapters/#{adapter_short}/**/*_test.rb"]).sort + + if ENV["BUILDKITE_PARALLEL_JOB_COUNT"] + n = ENV["BUILDKITE_PARALLEL_JOB"].to_i + m = ENV["BUILDKITE_PARALLEL_JOB_COUNT"].to_i + + test_files = test_files.each_slice(m).map { |slice| slice[n] }.compact + end + + test_files.each do |file| + puts "--- #{file}" + success = sh(Gem.ruby, "-w", "-Itest", file) + unless success + failing_files << file + puts "^^^ +++" + end + puts + end + + puts "--- All tests completed" + unless failing_files.empty? + puts "^^^ +++" + puts + puts "Failed in:" + failing_files.each do |file| + puts " #{file}" + end + puts + + exit 1 + end end end end diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index b0c0beac0e..c3d4eab562 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -209,8 +209,7 @@ module ActiveRecord # This method is abstract in the sense that it relies on # +count_records+, which is a method descendants have to provide. def size - if !find_target? - loaded! unless loaded? + if !find_target? || loaded? target.size elsif @association_ids @association_ids.size diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index 6f67934a79..5972846940 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -52,7 +52,11 @@ module ActiveRecord # If the collection is empty the target is set to an empty array and # the loaded flag is set to true as well. def count_records - count = counter_cache_value || scope.count(:all) + count = if reflection.has_cached_counter? + owner._read_attribute(reflection.counter_cache_column).to_i + else + scope.count(:all) + end # If there's nothing in the database and @target has no new records # we are certain the current target is an empty array. This is a @@ -62,14 +66,6 @@ module ActiveRecord [association_scope.limit_value, count].compact.min end - def counter_cache_value - reflection.has_cached_counter? ? owner._read_attribute(reflection.counter_cache_column).to_i : nil - end - - def find_target? - super && !counter_cache_value&.zero? - end - def update_counter(difference, reflection = reflection()) if reflection.has_cached_counter? owner.increment!(reflection.counter_cache_column, difference) diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb index c36b87829e..9879b01d3a 100644 --- a/activerecord/lib/active_record/database_configurations.rb +++ b/activerecord/lib/active_record/database_configurations.rb @@ -158,7 +158,7 @@ module ActiveRecord configs else configs.map do |config| - ActiveRecord::DatabaseConfigurations::UrlConfig.new(env, config.spec_name, url, config.config) + ActiveRecord::DatabaseConfigurations::UrlConfig.new(config.env_name, config.spec_name, url, config.config) end end else diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index 94906a2943..abc939826b 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -77,18 +77,18 @@ module ActiveRecord class V5_1 < V5_2 def change_column(table_name, column_name, type, options = {}) - if adapter_name == "PostgreSQL" + if connection.adapter_name == "PostgreSQL" super(table_name, column_name, type, options.except(:default, :null, :comment)) - change_column_default(table_name, column_name, options[:default]) if options.key?(:default) - change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) - change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) + connection.change_column_default(table_name, column_name, options[:default]) if options.key?(:default) + connection.change_column_null(table_name, column_name, options[:null], options[:default]) if options.key?(:null) + connection.change_column_comment(table_name, column_name, options[:comment]) if options.key?(:comment) else super end end def create_table(table_name, options = {}) - if adapter_name == "Mysql2" + if connection.adapter_name == "Mysql2" super(table_name, options: "ENGINE=InnoDB", **options) else super @@ -110,13 +110,13 @@ module ActiveRecord end def create_table(table_name, options = {}) - if adapter_name == "PostgreSQL" + if connection.adapter_name == "PostgreSQL" if options[:id] == :uuid && !options.key?(:default) options[:default] = "uuid_generate_v4()" end end - unless adapter_name == "Mysql2" && options[:id] == :bigint + unless connection.adapter_name == "Mysql2" && options[:id] == :bigint if [:integer, :bigint].include?(options[:id]) && !options.key?(:default) options[:default] = nil end @@ -190,7 +190,7 @@ module ActiveRecord if options[:name].present? options[:name].to_s else - index_name(table_name, column: column_names) + connection.index_name(table_name, column: column_names) end super end @@ -210,15 +210,17 @@ module ActiveRecord end def index_name_for_remove(table_name, options = {}) - index_name = index_name(table_name, options) + index_name = connection.index_name(table_name, options) - unless index_name_exists?(table_name, index_name) + unless connection.index_name_exists?(table_name, index_name) if options.is_a?(Hash) && options.has_key?(:name) options_without_column = options.dup options_without_column.delete :column - index_name_without_column = index_name(table_name, options_without_column) + index_name_without_column = connection.index_name(table_name, options_without_column) - return index_name_without_column if index_name_exists?(table_name, index_name_without_column) + if connection.index_name_exists?(table_name, index_name_without_column) + return index_name_without_column + end end raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' does not exist" diff --git a/activerecord/lib/active_record/querying.rb b/activerecord/lib/active_record/querying.rb index 8c1b2e2be1..2a27f53f06 100644 --- a/activerecord/lib/active_record/querying.rb +++ b/activerecord/lib/active_record/querying.rb @@ -40,8 +40,7 @@ module ActiveRecord def find_by_sql(sql, binds = [], preparable: nil, &block) result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds, preparable: preparable) column_types = result_set.column_types.dup - cached_columns_hash = connection.schema_cache.columns_hash(table_name) - cached_columns_hash.each_key { |k| column_types.delete k } + attribute_types.each_key { |k| column_types.delete k } message_bus = ActiveSupport::Notifications.instrumenter payload = { diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 347d745d19..f98d9bb2c0 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -321,12 +321,12 @@ module ActiveRecord # Please check unscoped if you want to remove all previous scopes (including # the default_scope) during the execution of a block. def scoping - @delegate_to_klass && klass.current_scope(true) ? yield : _scoping(self) { yield } + @delegate_to_klass ? yield : _scoping(self) { yield } end def _exec_scope(*args, &block) # :nodoc: @delegate_to_klass = true - _scoping(nil) { instance_exec(*args, &block) || self } + instance_exec(*args, &block) || self ensure @delegate_to_klass = false end diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index cef31bea94..bdd3c540bb 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -186,11 +186,9 @@ module ActiveRecord relation = apply_join_dependency relation.pluck(*column_names) else - disallow_raw_sql!(column_names) + klass.disallow_raw_sql!(column_names) relation = spawn - relation.select_values = column_names.map { |cn| - @klass.has_attribute?(cn) || @klass.attribute_alias?(cn) ? arel_attribute(cn) : cn - } + relation.select_values = column_names result = skip_query_cache_if_necessary { klass.connection.select_all(relation.arel, nil) } result.cast_values(klass.attribute_types) end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index eb80aab701..75976aa8fc 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1052,11 +1052,13 @@ module ActiveRecord def arel_columns(columns) columns.flat_map do |field| - if (Symbol === field || String === field) && (klass.has_attribute?(field) || klass.attribute_alias?(field)) && !from_clause.value - arel_attribute(field) - elsif Symbol === field - connection.quote_table_name(field.to_s) - elsif Proc === field + case field + when Symbol + field = field.to_s + arel_column(field) { connection.quote_table_name(field) } + when String + arel_column(field) { field } + when Proc field.call else field @@ -1064,6 +1066,16 @@ module ActiveRecord end end + def arel_column(field) + field = klass.attribute_alias(field) if klass.attribute_alias?(field) + + if klass.columns_hash.key?(field) && !from_clause.value + arel_attribute(field) + else + yield + end + end + def reverse_sql_order(order_query) if order_query.empty? return [arel_attribute(primary_key).desc] if primary_key diff --git a/activerecord/lib/active_record/relation/spawn_methods.rb b/activerecord/lib/active_record/relation/spawn_methods.rb index 8cf4fc469f..7874c4c35a 100644 --- a/activerecord/lib/active_record/relation/spawn_methods.rb +++ b/activerecord/lib/active_record/relation/spawn_methods.rb @@ -8,7 +8,7 @@ module ActiveRecord module SpawnMethods # This is overridden by Associations::CollectionProxy def spawn #:nodoc: - @delegate_to_klass && klass.current_scope(true) ? klass.all : clone + @delegate_to_klass ? klass.all : clone end # Merges in the conditions from <tt>other</tt>, if <tt>other</tt> is an ActiveRecord::Relation. diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index c01138c059..3525fa2ab8 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -448,8 +448,13 @@ class BelongsToAssociationsTest < ActiveRecord::TestCase end def test_with_select - assert_equal 1, Company.find(2).firm_with_select.attributes.size - assert_equal 1, Company.all.merge!(includes: :firm_with_select).find(2).firm_with_select.attributes.size + assert_equal 1, Post.find(2).author_with_select.attributes.size + assert_equal 1, Post.includes(:author_with_select).find(2).author_with_select.attributes.size + end + + def test_custom_attribute_with_select + assert_equal 2, Company.find(2).firm_with_select.attributes.size + assert_equal 2, Company.includes(:firm_with_select).find(2).firm_with_select.attributes.size end def test_belongs_to_without_counter_cache_option diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index bf44be1811..7d669198ca 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -1230,10 +1230,7 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_not_predicate Ship.reflect_on_association(:treasures), :has_cached_counter? # Count should come from sql count() of treasures rather than treasures_count attribute - assert_queries(1) do - assert_equal ship.treasures.size, 0 - assert_predicate ship.treasures, :loaded? - end + assert_equal ship.treasures.size, 0 assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed" do ship.treasures.create(name: "Gold") @@ -1354,20 +1351,6 @@ class HasManyAssociationsTest < ActiveRecord::TestCase post = posts(:welcome) assert_no_queries do assert_not_empty post.comments - assert_equal 2, post.comments.size - assert_not_predicate post.comments, :loaded? - end - post = posts(:misc_by_bob) - assert_no_queries do - assert_empty post.comments - assert_predicate post.comments, :loaded? - end - end - - def test_empty_association_loading_with_counter_cache - post = posts(:misc_by_bob) - assert_no_queries do - assert_empty post.comments.to_a end end @@ -1755,6 +1738,14 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_nil posts.first end + def test_destroy_all_on_desynced_counter_cache_association + category = categories(:general) + assert_operator category.categorizations.count, :>, 0 + + category.categorizations.destroy_all + assert_equal 0, category.categorizations.count + end + def test_destroy_on_association_clears_scope author = Author.create!(name: "Gannon") posts = author.posts @@ -2004,6 +1995,11 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_not_predicate company.clients, :loaded? end + def test_counter_cache_on_unloaded_association + car = Car.create(name: "My AppliCar") + assert_equal 0, car.engines.size + end + def test_ids_reader_cache_not_used_for_size_when_association_is_dirty firm = Firm.create!(name: "Startup") assert_equal 0, firm.client_ids.size @@ -2019,24 +2015,6 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal [3, 11], firm.client_ids end - def test_zero_counter_cache_usage_on_unloaded_association - car = Car.create!(name: "My AppliCar") - assert_no_queries do - assert_equal car.engines.size, 0 - assert_predicate car.engines, :loaded? - end - end - - def test_counter_cache_on_new_record_unloaded_association - car = Car.new(name: "My AppliCar") - # Ensure no schema queries inside assertion - Engine.primary_key - assert_no_queries do - assert_equal car.engines.size, 0 - assert_predicate car.engines, :loaded? - end - end - def test_get_ids_ignores_include_option assert_equal [readers(:michael_welcome).id], posts(:welcome).readers_with_person_ids end diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index 363593ca19..1b8f748bad 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -1226,14 +1226,15 @@ class BasicsTest < ActiveRecord::TestCase end def test_attribute_names - assert_equal ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description"], - Company.attribute_names + expected = ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description", "metadata"] + assert_equal expected, Company.attribute_names end def test_has_attribute assert Company.has_attribute?("id") assert Company.has_attribute?("type") assert Company.has_attribute?("name") + assert Company.has_attribute?("metadata") assert_not Company.has_attribute?("lastname") assert_not Company.has_attribute?("age") end diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb index 850bc49676..b001667ac9 100644 --- a/activerecord/test/cases/calculations_test.rb +++ b/activerecord/test/cases/calculations_test.rb @@ -690,8 +690,9 @@ class CalculationsTest < ActiveRecord::TestCase end def test_pluck_not_auto_table_name_prefix_if_column_joined - Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)]) - assert_equal [7], Company.joins(:contracts).pluck(:developer_id) + company = Company.create!(name: "test", contracts: [Contract.new(developer_id: 7)]) + metadata = company.contracts.first.metadata + assert_equal [metadata], Company.joins(:contracts).pluck(:metadata) end def test_pluck_with_selection_clause diff --git a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb index 225cccc62c..515bf5df06 100644 --- a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb +++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb @@ -72,6 +72,16 @@ module ActiveRecord assert_equal expected, actual end + def test_resolver_with_database_uri_and_multiple_envs + ENV["DATABASE_URL"] = "postgres://localhost" + ENV["RAILS_ENV"] = "test" + + config = { "production" => { "adapter" => "postgresql", "database" => "foo_prod" }, "test" => { "adapter" => "postgresql", "database" => "foo_test" } } + actual = resolve_spec(:test, config) + expected = { "adapter" => "postgresql", "database" => "foo_test", "host" => "localhost", "name" => "test" } + assert_equal expected, actual + end + def test_resolver_with_database_uri_and_unknown_symbol_key ENV["DATABASE_URL"] = "postgres://localhost/foo" config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } diff --git a/activerecord/test/cases/database_selector_test.rb b/activerecord/test/cases/database_selector_test.rb index 4106a6ec46..fd02d2acb4 100644 --- a/activerecord/test/cases/database_selector_test.rb +++ b/activerecord/test/cases/database_selector_test.rb @@ -11,6 +11,10 @@ module ActiveRecord @session = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session.new(@session_store) end + teardown do + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + def test_empty_session assert_equal Time.at(0), @session.last_write_timestamp end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 0ab0459c38..857d743605 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -14,6 +14,7 @@ require "models/person" require "models/computer" require "models/reply" require "models/company" +require "models/contract" require "models/bird" require "models/car" require "models/engine" @@ -1815,6 +1816,12 @@ class RelationTest < ActiveRecord::TestCase assert_equal [1, 1, 1], posts.map(&:author_address_id) end + test "joins with select custom attribute" do + contract = Company.create!(name: "test").contracts.create! + company = Company.joins(:contracts).select(:id, :metadata).find(contract.company_id) + assert_equal contract.metadata, company.metadata + end + test "delegations do not leak to other classes" do Topic.all.by_lifo assert Topic.all.class.method_defined?(:by_lifo) diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb index 27f9df295f..1a3a95f168 100644 --- a/activerecord/test/cases/scoping/named_scoping_test.rb +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -447,9 +447,8 @@ class NamedScopingTest < ActiveRecord::TestCase assert_equal [posts(:sti_comments)], Post.with_special_comments.with_post(4).to_a.uniq end - def test_chaining_doesnt_leak_conditions_to_another_scopes - expected = Topic.where(approved: false).where(id: Topic.children.select(:parent_id)) - assert_equal expected.to_a, Topic.rejected.has_children.to_a + def test_class_method_in_scope + assert_equal [topics(:second)], topics(:first).approved_replies.ordered end def test_nested_scoping diff --git a/activerecord/test/models/company.rb b/activerecord/test/models/company.rb index 838f515aad..a0f48d23f1 100644 --- a/activerecord/test/models/company.rb +++ b/activerecord/test/models/company.rb @@ -13,6 +13,8 @@ class Company < AbstractCompany has_many :contracts has_many :developers, through: :contracts + attribute :metadata, :json + scope :of_first_firm, lambda { joins(account: :firm). where("firms.id" => 1) diff --git a/activerecord/test/models/contract.rb b/activerecord/test/models/contract.rb index 3f663375c4..89719775c4 100644 --- a/activerecord/test/models/contract.rb +++ b/activerecord/test/models/contract.rb @@ -5,7 +5,9 @@ class Contract < ActiveRecord::Base belongs_to :developer, primary_key: :id belongs_to :firm, foreign_key: "company_id" - before_save :hi + attribute :metadata, :json + + before_save :hi, :update_metadata after_save :bye attr_accessor :hi_count, :bye_count @@ -19,6 +21,10 @@ class Contract < ActiveRecord::Base @bye_count ||= 0 @bye_count += 1 end + + def update_metadata + self.metadata = { company_id: company_id, developer_id: developer_id } + end end class NewContract < Contract diff --git a/activerecord/test/models/post.rb b/activerecord/test/models/post.rb index e32cc59399..65d1fce66d 100644 --- a/activerecord/test/models/post.rb +++ b/activerecord/test/models/post.rb @@ -31,6 +31,7 @@ class Post < ActiveRecord::Base belongs_to :author_with_posts, -> { includes(:posts) }, class_name: "Author", foreign_key: :author_id belongs_to :author_with_address, -> { includes(:author_address) }, class_name: "Author", foreign_key: :author_id + belongs_to :author_with_select, -> { select(:id) }, class_name: "Author", foreign_key: :author_id def first_comment super.body diff --git a/activerecord/test/models/reply.rb b/activerecord/test/models/reply.rb index 0807bcf875..b35623a344 100644 --- a/activerecord/test/models/reply.rb +++ b/activerecord/test/models/reply.rb @@ -7,6 +7,8 @@ class Reply < Topic belongs_to :topic_with_primary_key, class_name: "Topic", primary_key: "title", foreign_key: "parent_title", counter_cache: "replies_count", touch: true has_many :replies, class_name: "SillyReply", dependent: :destroy, foreign_key: "parent_id" has_many :silly_unique_replies, dependent: :destroy, foreign_key: "parent_id" + + scope :ordered, -> { Reply.order(:id) } end class SillyReply < Topic diff --git a/activerecord/test/models/topic.rb b/activerecord/test/models/topic.rb index fdb461ed7f..75890c327a 100644 --- a/activerecord/test/models/topic.rb +++ b/activerecord/test/models/topic.rb @@ -10,9 +10,6 @@ class Topic < ActiveRecord::Base scope :approved, -> { where(approved: true) } scope :rejected, -> { where(approved: false) } - scope :children, -> { where.not(parent_id: nil) } - scope :has_children, -> { where(id: Topic.children.select(:parent_id)) } - scope :scope_with_lambda, lambda { all } scope :by_lifo, -> { where(author_name: "lifo") } diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 86d5a67a13..349b8afc48 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -247,6 +247,7 @@ ActiveRecord::Schema.define do create_table :contracts, force: true do |t| t.references :developer, index: false t.references :company, index: false + t.string :metadata end create_table :customers, force: true do |t| diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 7002474b8d..c46b56833d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,11 @@ +* New autoloading based on [Zeitwerk](https://github.com/fxn/zeitwerk). + + *Xavier Noria* + +* Revise `ActiveSupport::Notifications.unsubscribe` to correctly handle Regex or other multiple-pattern subscribers. + + *Zach Kemp* + * Add `before_reset` callback to `CurrentAttributes` and define `after_reset` as an alias of `resets` for symmetry. *Rosa Gutierrez* diff --git a/activesupport/activesupport.gemspec b/activesupport/activesupport.gemspec index bdd7bc70a0..92cdfd89fe 100644 --- a/activesupport/activesupport.gemspec +++ b/activesupport/activesupport.gemspec @@ -30,8 +30,9 @@ Gem::Specification.new do |s| # NOTE: Please read our dependency guidelines before updating versions: # https://edgeguides.rubyonrails.org/security.html#dependency-management-and-cves - s.add_dependency "i18n", ">= 0.7", "< 2" - s.add_dependency "tzinfo", "~> 1.1" - s.add_dependency "minitest", "~> 5.1" + s.add_dependency "i18n", ">= 0.7", "< 2" + s.add_dependency "tzinfo", "~> 1.1" + s.add_dependency "minitest", "~> 5.1" s.add_dependency "concurrent-ruby", "~> 1.0", ">= 1.0.2" + s.add_dependency "zeitwerk", "~> 1.0" end diff --git a/activesupport/lib/active_support/cache/file_store.rb b/activesupport/lib/active_support/cache/file_store.rb index de1fb1886c..f43894a1ea 100644 --- a/activesupport/lib/active_support/cache/file_store.rb +++ b/activesupport/lib/active_support/cache/file_store.rb @@ -18,7 +18,6 @@ module ActiveSupport DIR_FORMATTER = "%03X" FILENAME_MAX_SIZE = 228 # max filename size on file system is 255, minus room for timestamp and random characters appended by Tempfile (used by atomic write) FILEPATH_MAX_SIZE = 900 # max is 1024, plus some room - EXCLUDED_DIRS = [".", ".."].freeze GITKEEP_FILES = [".gitkeep", ".keep"].freeze def initialize(cache_path, options = nil) @@ -35,7 +34,7 @@ module ActiveSupport # file store directory except for .keep or .gitkeep. Be careful which directory is specified in your # config file when using +FileStore+ because everything in that directory will be deleted. def clear(options = nil) - root_dirs = exclude_from(cache_path, EXCLUDED_DIRS + GITKEEP_FILES) + root_dirs = (Dir.children(cache_path) - GITKEEP_FILES) FileUtils.rm_r(root_dirs.collect { |f| File.join(cache_path, f) }) rescue Errno::ENOENT end @@ -154,7 +153,7 @@ module ActiveSupport # Delete empty directories in the cache. def delete_empty_directories(dir) return if File.realpath(dir) == File.realpath(cache_path) - if exclude_from(dir, EXCLUDED_DIRS).empty? + if Dir.children(dir).empty? Dir.delete(dir) rescue nil delete_empty_directories(File.dirname(dir)) end @@ -167,8 +166,7 @@ module ActiveSupport def search_dir(dir, &callback) return if !File.exist?(dir) - Dir.foreach(dir) do |d| - next if EXCLUDED_DIRS.include?(d) + Dir.each_child(dir) do |d| name = File.join(dir, d) if File.directory?(name) search_dir(name, &callback) @@ -193,11 +191,6 @@ module ActiveSupport end end end - - # Exclude entries from source directory - def exclude_from(source, excludes) - Dir.entries(source).reject { |f| excludes.include?(f) } - end end end end diff --git a/activesupport/lib/active_support/dependencies/zeitwerk_integration.rb b/activesupport/lib/active_support/dependencies/zeitwerk_integration.rb new file mode 100644 index 0000000000..75624bae09 --- /dev/null +++ b/activesupport/lib/active_support/dependencies/zeitwerk_integration.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module ActiveSupport + module Dependencies + module ZeitwerkIntegration # :nodoc: all + module Decorations + def clear + Dependencies.unload_interlock do + Rails.autoloader.reload + end + end + + def constantize(cpath) + Inflector.constantize(cpath) + end + + def safe_constantize(cpath) + Inflector.safe_constantize(cpath) + end + + def autoloaded_constants + Rails.autoloaders.flat_map do |autoloader| + autoloader.loaded.to_a + end + end + + def autoloaded?(object) + cpath = object.is_a?(Module) ? object.name : object.to_s + Rails.autoloaders.any? { |autoloader| autoloader.loaded?(cpath) } + end + end + + class << self + def take_over + setup_autoloaders + freeze_autoload_paths + decorate_dependencies + end + + private + + def setup_autoloaders + Dependencies.autoload_paths.each do |autoload_path| + # Zeitwerk only accepts existing directories in `push_dir` to + # prevent misconfigurations. + next unless File.directory?(autoload_path) + + if autoload_once?(autoload_path) + Rails.once_autoloader.push_dir(autoload_path) + else + Rails.autoloader.push_dir(autoload_path) + end + end + + Rails.autoloaders.each(&:setup) + end + + def autoload_once?(autoload_path) + Dependencies.autoload_once_paths.include?(autoload_path) || + Gem.path.any? { |gem_path| autoload_path.to_s.start_with?(gem_path) } + end + + def freeze_autoload_paths + Dependencies.autoload_paths.freeze + Dependencies.autoload_once_paths.freeze + end + + def decorate_dependencies + Dependencies.singleton_class.prepend(Decorations) + Object.class_eval { alias_method :require_dependency, :require } + end + end + end + end +end diff --git a/activesupport/lib/active_support/notifications.rb b/activesupport/lib/active_support/notifications.rb index 7ccc333463..d9e93b530c 100644 --- a/activesupport/lib/active_support/notifications.rb +++ b/activesupport/lib/active_support/notifications.rb @@ -153,6 +153,15 @@ module ActiveSupport # # ActiveSupport::Notifications.unsubscribe("render") # + # Subscribers using a regexp or other pattern-matching object will remain subscribed + # to all events that match their original pattern, unless those events match a string + # passed to `unsubscribe`: + # + # subscriber = ActiveSupport::Notifications.subscribe(/render/) { } + # ActiveSupport::Notifications.unsubscribe('render_template.action_view') + # subscriber.matches?('render_template.action_view') # => false + # subscriber.matches?('render_partial.action_view') # => true + # # == Default Queue # # Notifications ships with a queue implementation that consumes and publishes events diff --git a/activesupport/lib/active_support/notifications/fanout.rb b/activesupport/lib/active_support/notifications/fanout.rb index 11721db103..f06504cf2c 100644 --- a/activesupport/lib/active_support/notifications/fanout.rb +++ b/activesupport/lib/active_support/notifications/fanout.rb @@ -2,6 +2,7 @@ require "mutex_m" require "concurrent/map" +require "set" module ActiveSupport module Notifications @@ -39,6 +40,7 @@ module ActiveSupport when String @string_subscribers[subscriber_or_name].clear @listeners_for.delete(subscriber_or_name) + @other_subscribers.each { |sub| sub.unsubscribe!(subscriber_or_name) } else pattern = subscriber_or_name.try(:pattern) if String === pattern @@ -113,11 +115,33 @@ module ActiveSupport end end + class Matcher #:nodoc: + attr_reader :pattern, :exclusions + + def self.wrap(pattern) + return pattern if String === pattern + new(pattern) + end + + def initialize(pattern) + @pattern = pattern + @exclusions = Set.new + end + + def unsubscribe!(name) + exclusions << -name if pattern === name + end + + def ===(name) + pattern === name && !exclusions.include?(name) + end + end + class Evented #:nodoc: attr_reader :pattern def initialize(pattern, delegate) - @pattern = pattern + @pattern = Matcher.wrap(pattern) @delegate = delegate @can_publish = delegate.respond_to?(:publish) end @@ -137,11 +161,15 @@ module ActiveSupport end def subscribed_to?(name) - @pattern === name + pattern === name end def matches?(name) - @pattern && @pattern === name + pattern && pattern === name + end + + def unsubscribe!(name) + pattern.unsubscribe!(name) end end @@ -204,6 +232,10 @@ module ActiveSupport true end + def unsubscribe!(*) + false + end + alias :matches? :=== end end diff --git a/activesupport/test/cache/stores/file_store_test.rb b/activesupport/test/cache/stores/file_store_test.rb index f6855bb308..0364d9ab64 100644 --- a/activesupport/test/cache/stores/file_store_test.rb +++ b/activesupport/test/cache/stores/file_store_test.rb @@ -101,7 +101,7 @@ class FileStoreTest < ActiveSupport::TestCase end assert File.exist?(cache_dir), "Parent of top level cache dir was deleted!" assert File.exist?(sub_cache_dir), "Top level cache dir was deleted!" - assert_empty Dir.entries(sub_cache_dir).reject { |f| ActiveSupport::Cache::FileStore::EXCLUDED_DIRS.include?(f) } + assert_empty Dir.children(sub_cache_dir) end def test_log_exception_when_cache_read_fails diff --git a/activesupport/test/notifications/evented_notification_test.rb b/activesupport/test/notifications/evented_notification_test.rb index 4beb8194b9..ab2a9b8659 100644 --- a/activesupport/test/notifications/evented_notification_test.rb +++ b/activesupport/test/notifications/evented_notification_test.rb @@ -84,6 +84,39 @@ module ActiveSupport [:finish, "hi", 1, {}] ], listener.events end + + def test_listen_to_regexp + notifier = Fanout.new + listener = Listener.new + notifier.subscribe(/[a-z]*.world/, listener) + notifier.start("hi.world", 1, {}) + notifier.finish("hi.world", 2, {}) + notifier.start("hello.world", 1, {}) + notifier.finish("hello.world", 2, {}) + + assert_equal [ + [:start, "hi.world", 1, {}], + [:finish, "hi.world", 2, {}], + [:start, "hello.world", 1, {}], + [:finish, "hello.world", 2, {}] + ], listener.events + end + + def test_listen_to_regexp_with_exclusions + notifier = Fanout.new + listener = Listener.new + notifier.subscribe(/[a-z]*.world/, listener) + notifier.unsubscribe("hi.world") + notifier.start("hi.world", 1, {}) + notifier.finish("hi.world", 2, {}) + notifier.start("hello.world", 1, {}) + notifier.finish("hello.world", 2, {}) + + assert_equal [ + [:start, "hello.world", 1, {}], + [:finish, "hello.world", 2, {}] + ], listener.events + end end end end diff --git a/activesupport/test/notifications_test.rb b/activesupport/test/notifications_test.rb index b5d72d1a42..bb20d26a25 100644 --- a/activesupport/test/notifications_test.rb +++ b/activesupport/test/notifications_test.rb @@ -128,6 +128,25 @@ module Notifications assert_equal [["named.subscription", :foo], ["named.subscription", :foo]], @events end + def test_unsubscribing_by_name_leaves_regexp_matched_subscriptions + @matched_events = [] + @notifier.subscribe(/subscription/) { |*args| @matched_events << event(*args) } + @notifier.publish("named.subscription", :before) + @notifier.wait + [@events, @named_events, @matched_events].each do |collector| + assert_includes(collector, ["named.subscription", :before]) + end + @notifier.unsubscribe("named.subscription") + @notifier.publish("named.subscription", :after) + @notifier.publish("other.subscription", :after) + @notifier.wait + assert_includes(@events, ["named.subscription", :after]) + assert_includes(@events, ["other.subscription", :after]) + assert_includes(@matched_events, ["other.subscription", :after]) + assert_not_includes(@matched_events, ["named.subscription", :after]) + assert_not_includes(@named_events, ["named.subscription", :after]) + end + private def event(*args) args diff --git a/guides/source/action_cable_overview.md b/guides/source/action_cable_overview.md index 8f5c44849a..7a9ff96c44 100644 --- a/guides/source/action_cable_overview.md +++ b/guides/source/action_cable_overview.md @@ -671,6 +671,21 @@ To configure the URL, add a call to `action_cable_meta_tag` in your HTML layout HEAD. This uses a URL or path typically set via `config.action_cable.url` in the environment configuration files. +### Worker Pool Configuration + +The worker pool is used to run connection callbacks and channel actions in +isolation from the server's main thread. Action Cable allows the application +to configure the number of simultaneously processed threads in the worker pool. + +```ruby +config.action_cable.worker_pool_size = 4 +``` + +Also, note that your server must provide at least the same number of database +connections as you have workers. The default worker pool size is set to 4, so +that means you have to make at least 4 database connections available. + You can change that in `config/database.yml` through the `pool` attribute. + ### Other Configurations The other common option to configure is the log tags applied to the @@ -688,11 +703,6 @@ config.action_cable.log_tags = [ For a full list of all configuration options, see the `ActionCable::Server::Configuration` class. -Also, note that your server must provide at least the same number of database -connections as you have workers. The default worker pool size is set to 4, so -that means you have to make at least that available. You can change that in -`config/database.yml` through the `pool` attribute. - ## Running Standalone Cable Servers ### In App diff --git a/guides/source/configuring.md b/guides/source/configuring.md index e7129dd274..ad82614a0d 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -791,6 +791,9 @@ main application. You can set this as nil to not mount Action Cable as part of your normal Rails server. +You can find more detailed configuration options in the +[Action Cable Overview](action_cable_overview.html#configuration). + ### Configuring Active Storage diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 19f4de8a1d..d66add2ca0 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,8 @@ +* Fix non-symbol access to nested hashes returned from `Rails::Application.config_for` + being broken by allowing non-symbol access with a deprecation notice. + + *Ufuk Kayserilioglu* + * Fix deeply nested namespace command printing. *Gannon McGibbon* diff --git a/railties/lib/rails.rb b/railties/lib/rails.rb index 092105d502..bca2cf34e1 100644 --- a/railties/lib/rails.rb +++ b/railties/lib/rails.rb @@ -110,5 +110,25 @@ module Rails def public_path application && Pathname.new(application.paths["public"].first) end + + def autoloader + if configuration.autoloader == :zeitwerk + @autoloader ||= Zeitwerk::Loader.new + end + end + + def once_autoloader + if configuration.autoloader == :zeitwerk + @once_autoloader ||= Zeitwerk::Loader.new + end + end + + def autoloaders + if configuration.autoloader == :zeitwerk + [autoloader, once_autoloader] + else + [] + end + end end end diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 5a924ab8e6..d0417f8a49 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -7,6 +7,7 @@ require "active_support/key_generator" require "active_support/message_verifier" require "active_support/encrypted_configuration" require "active_support/deprecation" +require "active_support/hash_with_indifferent_access" require "rails/engine" require "rails/secrets" @@ -230,8 +231,8 @@ module Rails config = YAML.load(ERB.new(yaml.read).result) || {} config = (config["shared"] || {}).merge(config[env] || {}) - ActiveSupport::OrderedOptions.new.tap do |config_as_ordered_options| - config_as_ordered_options.update(config.deep_symbolize_keys) + ActiveSupport::OrderedOptions.new.tap do |options| + options.update(NonSymbolAccessDeprecatedHash.new(config)) end else raise "Could not load configuration. No such file - #{yaml}" @@ -590,5 +591,38 @@ module Rails def build_middleware config.app_middleware + super end + + class NonSymbolAccessDeprecatedHash < HashWithIndifferentAccess # :nodoc: + def initialize(value = nil) + if value.is_a?(Hash) + value.each_pair { |k, v| self[k] = v } + else + super + end + end + + def []=(key, value) + if value.is_a?(Hash) + value = self.class.new(value) + end + super(key.to_sym, value) + end + + private + + def convert_key(key) + unless key.kind_of?(Symbol) + ActiveSupport::Deprecation.warn(<<~MESSAGE.squish) + Accessing hashes returned from config_for by non-symbol keys + is deprecated and will be removed in Rails 6.1. + Use symbols for access instead. + MESSAGE + + key = key.to_sym + end + + key + end + end end end diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index b7838f7e32..af3ec36064 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -20,7 +20,7 @@ module Rails :read_encrypted_secrets, :log_level, :content_security_policy_report_only, :content_security_policy_nonce_generator, :require_master_key, :credentials - attr_reader :encoding, :api_only, :loaded_config_version + attr_reader :encoding, :api_only, :loaded_config_version, :autoloader def initialize(*) super @@ -64,6 +64,7 @@ module Rails @credentials = ActiveSupport::OrderedOptions.new @credentials.content_path = default_credentials_content_path @credentials.key_path = default_credentials_key_path + @autoloader = :classic end def load_defaults(target_version) @@ -117,6 +118,8 @@ module Rails when "6.0" load_defaults "5.2" + self.autoloader = :zeitwerk if RUBY_ENGINE == "ruby" + if respond_to?(:action_view) action_view.default_enforce_utf8 = false end @@ -267,6 +270,18 @@ module Rails end end + def autoloader=(autoloader) + case autoloader + when :classic + @autoloader = autoloader + when :zeitwerk + require "zeitwerk" + @autoloader = autoloader + else + raise ArgumentError, "config.autoloader may be :classic or :zeitwerk, got #{autoloader.inspect} instead" + end + end + class Custom #:nodoc: def initialize @configurations = Hash.new diff --git a/railties/lib/rails/application/finisher.rb b/railties/lib/rails/application/finisher.rb index 04aaf6dd9a..39e8ef6631 100644 --- a/railties/lib/rails/application/finisher.rb +++ b/railties/lib/rails/application/finisher.rb @@ -21,6 +21,13 @@ module Rails end end + initializer :let_zeitwerk_take_over do + if config.autoloader == :zeitwerk + require "active_support/dependencies/zeitwerk_integration" + ActiveSupport::Dependencies::ZeitwerkIntegration.take_over + end + end + initializer :add_builtin_route do |app| if Rails.env.development? app.routes.prepend do @@ -66,6 +73,7 @@ module Rails initializer :eager_load! do if config.eager_load ActiveSupport.run_load_hooks(:before_eager_load, self) + Zeitwerk::Loader.eager_load_all if defined?(Zeitwerk) config.eager_load_namespaces.each(&:eager_load!) end end diff --git a/railties/lib/rails/command/behavior.rb b/railties/lib/rails/command/behavior.rb index 7f32b04cf1..7fb2a99e67 100644 --- a/railties/lib/rails/command/behavior.rb +++ b/railties/lib/rails/command/behavior.rb @@ -71,8 +71,9 @@ module Rails paths = [] namespaces.each do |namespace| pieces = namespace.split(":") - paths << pieces.dup.push(pieces.last).join("/") - paths << pieces.join("/") + path = pieces.join("/") + paths << "#{path}/#{pieces.last}" + paths << path end paths.uniq! paths diff --git a/railties/lib/rails/engine.rb b/railties/lib/rails/engine.rb index d6c329b581..2485158a7b 100644 --- a/railties/lib/rails/engine.rb +++ b/railties/lib/rails/engine.rb @@ -472,12 +472,10 @@ module Rails # Eager load the application by loading all ruby # files inside eager_load paths. def eager_load! - config.eager_load_paths.each do |load_path| - # Starts after load_path plus a slash, ends before ".rb". - relname_range = (load_path.to_s.length + 1)...-3 - Dir.glob("#{load_path}/**/*.rb").sort.each do |file| - require_dependency file[relname_range] - end + if Rails.autoloader + eager_load_with_zeitwerk! + else + eager_load_with_dependencies! end end @@ -653,6 +651,22 @@ module Rails private + def eager_load_with_zeitwerk! + (config.eager_load_paths - Zeitwerk::Loader.all_dirs).each do |path| + Dir.glob("#{path}/**/*.rb").sort.each { |file| require file } + end + end + + def eager_load_with_dependencies! + config.eager_load_paths.each do |load_path| + # Starts after load_path plus a slash, ends before ".rb". + relname_range = (load_path.to_s.length + 1)...-3 + Dir.glob("#{load_path}/**/*.rb").sort.each do |file| + require_dependency file[relname_range] + end + end + end + def load_config_initializer(initializer) # :doc: ActiveSupport::Notifications.instrument("load_config_initializer.railties", initializer: initializer) do load(initializer) diff --git a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt index d39b5d311f..783254b54d 100644 --- a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt @@ -28,7 +28,7 @@ ruby <%= "'#{RUBY_VERSION}'" -%> <% if depend_on_bootsnap? -%> # Reduces boot times through caching; required in config/boot.rb -gem 'bootsnap', '>= 1.1.0', require: false +gem 'bootsnap', '>= 1.4.0', require: false <%- end -%> <%- if options.api? -%> diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 37fba72ee3..960f708bdf 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -1157,6 +1157,27 @@ module ApplicationTests end end + test "autoloader & autoloader=" do + app "development" + + config = Rails.application.config + assert_instance_of Zeitwerk::Loader, Rails.autoloader + assert_instance_of Zeitwerk::Loader, Rails.once_autoloader + assert_equal [Rails.autoloader, Rails.once_autoloader], Rails.autoloaders + + config.autoloader = :classic + assert_nil Rails.autoloader + assert_nil Rails.once_autoloader + assert_empty Rails.autoloaders + + config.autoloader = :zeitwerk + assert_instance_of Zeitwerk::Loader, Rails.autoloader + assert_instance_of Zeitwerk::Loader, Rails.once_autoloader + assert_equal [Rails.autoloader, Rails.once_autoloader], Rails.autoloaders + + assert_raises(ArgumentError) { config.autoloader = :unknown } + end + test "config.action_view.cache_template_loading with cache_classes default" do add_to_config "config.cache_classes = true" @@ -1714,7 +1735,7 @@ module ApplicationTests assert_equal true, Rails.application.config.action_mailer.show_previews end - test "config_for loads custom configuration from yaml accessible as symbol" do + test "config_for loads custom configuration from yaml accessible as symbol or string" do app_file "config/custom.yml", <<-RUBY development: foo: 'bar' @@ -1727,6 +1748,7 @@ module ApplicationTests app "development" assert_equal "bar", Rails.application.config.my_custom_config[:foo] + assert_equal "bar", Rails.application.config.my_custom_config["foo"] end test "config_for loads nested custom configuration from yaml as symbol keys" do @@ -1746,6 +1768,25 @@ module ApplicationTests assert_equal 1, Rails.application.config.my_custom_config[:foo][:bar][:baz] end + test "config_for loads nested custom configuration from yaml with deprecated non-symbol access" do + app_file "config/custom.yml", <<-RUBY + development: + foo: + bar: + baz: 1 + RUBY + + add_to_config <<-RUBY + config.my_custom_config = config_for('custom') + RUBY + + app "development" + + assert_deprecated do + assert_equal 1, Rails.application.config.my_custom_config["foo"]["bar"]["baz"] + end + end + test "config_for makes all hash methods available" do app_file "config/custom.yml", <<-RUBY development: @@ -1762,12 +1803,93 @@ module ApplicationTests actual = Rails.application.config.my_custom_config - assert_equal actual, foo: 0, bar: { baz: 1 } - assert_equal actual.keys, [ :foo, :bar ] - assert_equal actual.values, [ 0, baz: 1] - assert_equal actual.to_h, foo: 0, bar: { baz: 1 } - assert_equal actual[:foo], 0 - assert_equal actual[:bar], baz: 1 + assert_equal({ foo: 0, bar: { baz: 1 } }, actual) + assert_equal([ :foo, :bar ], actual.keys) + assert_equal([ 0, baz: 1], actual.values) + assert_equal({ foo: 0, bar: { baz: 1 } }, actual.to_h) + assert_equal(0, actual[:foo]) + assert_equal({ baz: 1 }, actual[:bar]) + end + + test "config_for generates deprecation notice when nested hash methods are called with non-symbols" do + app_file "config/custom.yml", <<-RUBY + development: + foo: + bar: 1 + baz: 2 + qux: + boo: 3 + RUBY + + app "development" + + actual = Rails.application.config_for("custom")[:foo] + + # slice + assert_deprecated do + assert_equal({ bar: 1, baz: 2 }, actual.slice("bar", "baz")) + end + + # except + assert_deprecated do + assert_equal({ qux: { boo: 3 } }, actual.except("bar", "baz")) + end + + # dig + assert_deprecated do + assert_equal(3, actual.dig("qux", "boo")) + end + + # fetch - hit + assert_deprecated do + assert_equal(1, actual.fetch("bar", 0)) + end + + # fetch - miss + assert_deprecated do + assert_equal(0, actual.fetch("does-not-exist", 0)) + end + + # fetch_values + assert_deprecated do + assert_equal([1, 2], actual.fetch_values("bar", "baz")) + end + + # key? - hit + assert_deprecated do + assert(actual.key?("bar")) + end + + # key? - miss + assert_deprecated do + assert_not(actual.key?("does-not-exist")) + end + + # slice! + actual = Rails.application.config_for("custom")[:foo] + + assert_deprecated do + slice = actual.slice!("bar", "baz") + assert_equal({ bar: 1, baz: 2 }, actual) + assert_equal({ qux: { boo: 3 } }, slice) + end + + # extract! + actual = Rails.application.config_for("custom")[:foo] + + assert_deprecated do + extracted = actual.extract!("bar", "baz") + assert_equal({ bar: 1, baz: 2 }, extracted) + assert_equal({ qux: { boo: 3 } }, actual) + end + + # except! + actual = Rails.application.config_for("custom")[:foo] + + assert_deprecated do + actual.except!("bar", "baz") + assert_equal({ qux: { boo: 3 } }, actual) + end end test "config_for uses the Pathname object if it is provided" do diff --git a/railties/test/application/mailer_previews_test.rb b/railties/test/application/mailer_previews_test.rb index ba186bda44..fb84276b8a 100644 --- a/railties/test/application/mailer_previews_test.rb +++ b/railties/test/application/mailer_previews_test.rb @@ -85,6 +85,7 @@ module ApplicationTests end test "mailer previews are loaded from a custom preview_path" do + app_dir "lib/mailer_previews" add_to_config "config.action_mailer.preview_path = '#{app_path}/lib/mailer_previews'" mailer "notifier", <<-RUBY @@ -254,6 +255,7 @@ module ApplicationTests end test "mailer previews are reloaded from a custom preview_path" do + app_dir "lib/mailer_previews" add_to_config "config.action_mailer.preview_path = '#{app_path}/lib/mailer_previews'" app("development") @@ -818,6 +820,7 @@ module ApplicationTests def build_app super app_file "config/routes.rb", "Rails.application.routes.draw do; end" + app_dir "test/mailers/previews" end def mailer(name, contents) diff --git a/railties/test/application/multiple_applications_test.rb b/railties/test/application/multiple_applications_test.rb index 432344bccc..f0f1112f6b 100644 --- a/railties/test/application/multiple_applications_test.rb +++ b/railties/test/application/multiple_applications_test.rb @@ -100,30 +100,6 @@ module ApplicationTests assert_nothing_raised { AppTemplate::Application.new } end - def test_initializers_run_on_different_applications_go_to_the_same_class - application1 = AppTemplate::Application.new - run_count = 0 - - AppTemplate::Application.initializer :init0 do - run_count += 1 - end - - application1.initializer :init1 do - run_count += 1 - end - - AppTemplate::Application.new.initializer :init2 do - run_count += 1 - end - - assert_equal 0, run_count, "Without loading the initializers, the count should be 0" - - # Set config.eager_load to false so that an eager_load warning doesn't pop up - AppTemplate::Application.create { config.eager_load = false }.initialize! - - assert_equal 3, run_count, "There should have been three initializers that incremented the count" - end - def test_consoles_run_on_different_applications_go_to_the_same_class run_count = 0 AppTemplate::Application.console { run_count += 1 } diff --git a/railties/test/application/rake_test.rb b/railties/test/application/rake_test.rb index 44e3b0f66b..fe56e3d076 100644 --- a/railties/test/application/rake_test.rb +++ b/railties/test/application/rake_test.rb @@ -145,8 +145,8 @@ module ApplicationTests # loading a specific fixture rails "db:fixtures:load", "FIXTURES=products" - assert_equal 2, ::AppTemplate::Application::Product.count - assert_equal 0, ::AppTemplate::Application::User.count + assert_equal 2, Product.count + assert_equal 0, User.count end def test_loading_only_yml_fixtures diff --git a/railties/test/application/zeitwerk_integration_test.rb b/railties/test/application/zeitwerk_integration_test.rb new file mode 100644 index 0000000000..628a85acd8 --- /dev/null +++ b/railties/test/application/zeitwerk_integration_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" +require "active_support/dependencies/zeitwerk_integration" + +class ZeitwerkIntegrationTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + def setup + build_app + end + + def boot(env = "development") + app(env) + end + + def teardown + teardown_app + end + + def deps + ActiveSupport::Dependencies + end + + def decorated? + deps.singleton_class < deps::ZeitwerkIntegration::Decorations + end + + test "ActiveSupport::Dependencies is decorated by default" do + boot + + assert decorated? + assert_instance_of Zeitwerk::Loader, Rails.autoloader + assert_instance_of Zeitwerk::Loader, Rails.once_autoloader + assert_equal [Rails.autoloader, Rails.once_autoloader], Rails.autoloaders + end + + test "ActiveSupport::Dependencies is not decorated in classic mode" do + add_to_config "config.autoloader = :classic" + boot + + assert_not decorated? + assert_nil Rails.autoloader + assert_nil Rails.once_autoloader + assert_empty Rails.autoloaders + end + + test "constantize returns the value stored in the constant" do + app_file "app/models/admin/user.rb", "class Admin::User; end" + boot + + assert_same Admin::User, deps.constantize("Admin::User") + end + + test "constantize raises if the constant is unknown" do + boot + + assert_raises(NameError) { deps.constantize("Admin") } + end + + test "safe_constantize returns the value stored in the constant" do + app_file "app/models/admin/user.rb", "class Admin::User; end" + boot + + assert_same Admin::User, deps.safe_constantize("Admin::User") + end + + test "safe_constantize returns nil for unknown constants" do + boot + + assert_nil deps.safe_constantize("Admin") + end + + test "autoloaded_constants returns autoloaded constant paths" do + app_file "app/models/admin/user.rb", "class Admin::User; end" + app_file "app/models/post.rb", "class Post; end" + boot + + assert Admin::User + assert_equal ["Admin", "Admin::User"], deps.autoloaded_constants + end + + test "autoloaded? says if a constant has been autoloaded" do + app_file "app/models/user.rb", "class User; end" + app_file "app/models/post.rb", "class Post; end" + boot + + assert Post + assert deps.autoloaded?("Post") + assert deps.autoloaded?(Post) + assert_not deps.autoloaded?("User") + end + + test "eager loading loads the application code" do + $zeitwerk_integration_test_user = false + $zeitwerk_integration_test_post = false + + app_file "app/models/user.rb", "class User; end; $zeitwerk_integration_test_user = true" + app_file "app/models/post.rb", "class Post; end; $zeitwerk_integration_test_post = true" + boot("production") + + assert $zeitwerk_integration_test_user + assert $zeitwerk_integration_test_post + end + + test "eager loading loads anything managed by Zeitwerk" do + $zeitwerk_integration_test_user = false + app_file "app/models/user.rb", "class User; end; $zeitwerk_integration_test_user = true" + + $zeitwerk_integration_test_extras = false + app_dir "extras" + app_file "extras/webhook_hacks.rb", "WebhookHacks = 1; $zeitwerk_integration_test_extras = true" + + require "zeitwerk" + autoloader = Zeitwerk::Loader.new + autoloader.push_dir("#{app_path}/extras") + autoloader.setup + + boot("production") + + assert $zeitwerk_integration_test_user + assert $zeitwerk_integration_test_extras + end + + test "autoload paths that are below Gem.path go to the once autoloader" do + app_dir "extras" + add_to_config 'config.autoload_paths << "#{Rails.root}/extras"' + + # Mocks Gem.path to include the extras directory. + Gem.singleton_class.prepend( + Module.new do + def path + super + ["#{Rails.root}/extras"] + end + end + ) + boot + + assert_not_includes Rails.autoloader.dirs, "#{app_path}/extras" + assert_includes Rails.once_autoloader.dirs, "#{app_path}/extras" + end + + test "clear reloads the main autoloader, and does not reload the once one" do + boot + + $zeitwerk_integration_reload_test = [] + + autoloader = Rails.autoloader + def autoloader.reload + $zeitwerk_integration_reload_test << :autoloader + super + end + + once_autoloader = Rails.once_autoloader + def once_autoloader.reload + $zeitwerk_integration_reload_test << :once_autoloader + super + end + + ActiveSupport::Dependencies.clear + + assert_equal %i(autoloader), $zeitwerk_integration_reload_test + end +end diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb index 0e8e0e86ee..4442cdf4bf 100644 --- a/railties/test/isolation/abstract_unit.rb +++ b/railties/test/isolation/abstract_unit.rb @@ -421,6 +421,10 @@ module TestHelpers file_name end + def app_dir(path) + FileUtils.mkdir_p("#{app_path}/#{path}") + end + def remove_file(path) FileUtils.rm_rf "#{app_path}/#{path}" end diff --git a/railties/test/isolation/assets/package.json b/railties/test/isolation/assets/package.json index 106b1029f0..7c34450fe0 100644 --- a/railties/test/isolation/assets/package.json +++ b/railties/test/isolation/assets/package.json @@ -2,6 +2,8 @@ "name": "dummy", "private": true, "dependencies": { + "@rails/actioncable": "file:../../../../actioncable", + "@rails/activestorage": "file:../../../../activestorage", "@rails/ujs": "file:../../../../actionview", "@rails/webpacker": "https://github.com/rails/webpacker.git", "turbolinks": "^5.2.0" diff --git a/railties/test/railties/engine_test.rb b/railties/test/railties/engine_test.rb index 851407dede..69f6e34d58 100644 --- a/railties/test/railties/engine_test.rb +++ b/railties/test/railties/engine_test.rb @@ -704,25 +704,27 @@ YAML RUBY @plugin.write "app/controllers/bukkits/foo_controller.rb", <<-RUBY - class Bukkits::FooController < ActionController::Base - def index - render inline: "<%= help_the_engine %>" - end + module Bukkits + class FooController < ActionController::Base + def index + render inline: "<%= help_the_engine %>" + end - def show - render plain: foo_path - end + def show + render plain: foo_path + end - def from_app - render inline: "<%= (self.respond_to?(:bar_path) || self.respond_to?(:something)) %>" - end + def from_app + render inline: "<%= (self.respond_to?(:bar_path) || self.respond_to?(:something)) %>" + end - def routes_helpers_in_view - render inline: "<%= foo_path %>, <%= main_app.bar_path %>" - end + def routes_helpers_in_view + render inline: "<%= foo_path %>, <%= main_app.bar_path %>" + end - def polymorphic_path_without_namespace - render plain: polymorphic_path(Post.new) + def polymorphic_path_without_namespace + render plain: polymorphic_path(Post.new) + end end end RUBY |