aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--activesupport/CHANGELOG.md5
-rw-r--r--activesupport/lib/active_support.rb1
-rw-r--r--activesupport/lib/active_support/current_attributes.rb190
-rw-r--r--activesupport/lib/active_support/railtie.rb5
-rw-r--r--activesupport/test/current_attributes_test.rb96
-rw-r--r--railties/test/application/current_attributes_integration_test.rb88
6 files changed, 385 insertions, 0 deletions
diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md
index bae573cf37..b5db0693fe 100644
--- a/activesupport/CHANGELOG.md
+++ b/activesupport/CHANGELOG.md
@@ -1,3 +1,8 @@
+* Add ActiveSupport::CurrentAttributes to provide a thread-isolated attributes singleton.
+ Primary use case is keeping all the per-request attributes easily available to the whole system.
+
+ *DHH*
+
* Fix implicit coercion calculations with scalars and durations
Previously calculations where the scalar is first would be converted to a duration
diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb
index 03e3ce821a..a667fbb54a 100644
--- a/activesupport/lib/active_support.rb
+++ b/activesupport/lib/active_support.rb
@@ -32,6 +32,7 @@ module ActiveSupport
extend ActiveSupport::Autoload
autoload :Concern
+ autoload :CurrentAttributes
autoload :Dependencies
autoload :DescendantsTracker
autoload :ExecutionWrapper
diff --git a/activesupport/lib/active_support/current_attributes.rb b/activesupport/lib/active_support/current_attributes.rb
new file mode 100644
index 0000000000..6b859cc6cc
--- /dev/null
+++ b/activesupport/lib/active_support/current_attributes.rb
@@ -0,0 +1,190 @@
+module ActiveSupport
+ # Abstract super class that provides a thread-isolated attributes singleton, which resets automatically
+ # before and after reach request. This allows you to keep all the per-request attributes easily
+ # available to the whole system.
+ #
+ # The following full app-like example demonstrates how to use a Current class to
+ # facilitate easy access to the global, per-request attributes without passing them deeply
+ # around everywhere:
+ #
+ # # app/models/current.rb
+ # class Current < ActiveSupport::CurrentAttributes
+ # attribute :account, :user
+ # attribute :request_id, :user_agent, :ip_address
+ #
+ # resets { Time.zone = nil }
+ #
+ # def user=(user)
+ # super
+ # self.account = user.account
+ # Time.zone = user.time_zone
+ # end
+ # end
+ #
+ # # app/controllers/concerns/authentication.rb
+ # module Authentication
+ # extend ActiveSupport::Concern
+ #
+ # included do
+ # before_action :authenticate
+ # end
+ #
+ # private
+ # def authenticate
+ # if authenticated_user = User.find(cookies.signed[:user_id])
+ # Current.user = authenticated_user
+ # else
+ # redirect_to new_session_url
+ # end
+ # end
+ # end
+ #
+ # # app/controllers/concerns/set_current_request_details.rb
+ # module SetCurrentRequestDetails
+ # extend ActiveSupport::Concern
+ #
+ # included do
+ # before_action do
+ # Current.request_id = request.uuid
+ # Current.user_agent = request.user_agent
+ # Current.ip_address = request.ip
+ # end
+ # end
+ # end
+ #
+ # class ApplicationController < ActionController::Base
+ # include Authentication
+ # include SetCurrentRequestDetails
+ # end
+ #
+ # class MessagesController < ApplicationController
+ # def create
+ # Current.account.messages.create(message_params)
+ # end
+ # end
+ #
+ # class Message < ApplicationRecord
+ # belongs_to :creator, default: -> { Current.user }
+ # after_create { |message| Event.create(record: message) }
+ # end
+ #
+ # class Event < ApplicationRecord
+ # before_create do
+ # self.request_id = Current.request_id
+ # self.user_agent = Current.user_agent
+ # self.ip_address = Current.ip_address
+ # end
+ # end
+ #
+ # A word of caution: It's easy to overdo a global singleton like Current and tangle your model as a result.
+ # Current should only be used for a few, top-level globals, like account, user, and request details.
+ # The attributes stuck in Current should be used by more or less all actions on all requests. If you start
+ # sticking controller-specific attributes in there, you're going to create a mess.
+ class CurrentAttributes
+ include ActiveSupport::Callbacks
+ define_callbacks :reset
+
+ class << self
+ # Returns singleton instance for this class in this thread. If none exists, one is created.
+ def instance
+ Thread.current[:"current_attributes_for_#{name}"] ||= new.tap do |instance|
+ current_instances << instance
+ end
+ end
+
+ # Declares one or more attributes that will be given both class and instance accessor methods.
+ def attribute(*names)
+ generated_attribute_methods.module_eval do
+ names.each do |name|
+ define_method(name) do
+ attributes[name.to_sym]
+ end
+
+ define_method("#{name}=") do |attribute|
+ attributes[name.to_sym] = attribute
+ 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)
+ end
+ end
+ end
+
+ # Calls this block after #reset is called on the instance. Used for resetting external collaborators, like Time.zone.
+ def resets(&block)
+ set_callback :reset, :after, &block
+ end
+
+ delegate :set, :reset, to: :instance
+
+ def reset_all # :nodoc:
+ current_instances.each(&:reset)
+ end
+
+ private
+ def generated_attribute_methods
+ @generated_attribute_methods ||= Module.new.tap { |mod| include mod }
+ end
+
+ def current_instances
+ Thread.current[:current_attributes_instances] ||= []
+ end
+
+ def method_missing(name, *args, &block)
+ # Caches the method definition as a singleton method of the receiver.
+ #
+ # By letting #delegate handle it, we avoid an enclosure that'll capture args.
+ singleton_class.delegate name, to: :instance
+
+ send(name, *args, &block)
+ end
+ end
+
+ attr_accessor :attributes
+
+ def initialize
+ @attributes = {}
+ end
+
+ # Expose one or more attributes within a block. Old values are returned after the block concludes.
+ # Example demonstrating the common use of needing to set Current attributes outside the request-cycle:
+ #
+ # class Chat::PublicationJob < ApplicationJob
+ # def perform(attributes, room_number, creator)
+ # Current.set(person: creator) do
+ # Chat::Publisher.publish(attributes: attributes, room_number: room_number)
+ # end
+ # end
+ # end
+ def set(set_attributes)
+ old_attributes = compute_attributes(set_attributes.keys)
+ assign_attributes(set_attributes)
+ yield
+ ensure
+ assign_attributes(old_attributes)
+ end
+
+ # Reset all attributes. Should be called before and after actions, when used as a per-request singleton.
+ def reset
+ run_callbacks :reset do
+ self.attributes = {}
+ end
+ end
+
+ private
+ def assign_attributes(new_attributes)
+ new_attributes.each { |key, value| public_send("#{key}=", value) }
+ end
+
+ def compute_attributes(keys)
+ keys.collect { |key| [ key, public_send(key) ] }.to_h
+ end
+ end
+end
diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb
index b875875afe..39c83f65a3 100644
--- a/activesupport/lib/active_support/railtie.rb
+++ b/activesupport/lib/active_support/railtie.rb
@@ -7,6 +7,11 @@ module ActiveSupport
config.eager_load_namespaces << ActiveSupport
+ initializer "active_support.reset_all_current_attributes_instances" do |app|
+ app.executor.to_run { ActiveSupport::CurrentAttributes.reset_all }
+ app.executor.to_complete { ActiveSupport::CurrentAttributes.reset_all }
+ end
+
initializer "active_support.deprecation_behavior" do |app|
if deprecation = app.config.active_support.deprecation
ActiveSupport::Deprecation.behavior = deprecation
diff --git a/activesupport/test/current_attributes_test.rb b/activesupport/test/current_attributes_test.rb
new file mode 100644
index 0000000000..67ef6ef619
--- /dev/null
+++ b/activesupport/test/current_attributes_test.rb
@@ -0,0 +1,96 @@
+require "abstract_unit"
+
+class CurrentAttributesTest < ActiveSupport::TestCase
+ Person = Struct.new(:name, :time_zone)
+
+ class Current < ActiveSupport::CurrentAttributes
+ attribute :world, :account, :person, :request
+ delegate :time_zone, to: :person
+
+ resets { Time.zone = "UTC" }
+
+ def account=(account)
+ super
+ self.person = "#{account}'s person"
+ end
+
+ def person=(person)
+ super
+ Time.zone = person.try(:time_zone)
+ end
+
+ def request
+ "#{super} something"
+ end
+
+ def intro
+ "#{person.name}, in #{time_zone}"
+ end
+ end
+
+ setup { Current.reset }
+
+ test "read and write attribute" do
+ Current.world = "world/1"
+ assert_equal "world/1", Current.world
+ end
+
+ test "read overwritten attribute method" do
+ Current.request = "request/1"
+ assert_equal "request/1 something", Current.request
+ end
+
+ test "set attribute via overwritten method" do
+ Current.account = "account/1"
+ assert_equal "account/1", Current.account
+ assert_equal "account/1's person", Current.person
+ end
+
+ test "set auxiliary class via overwritten method" do
+ Current.person = Person.new("David", "Central Time (US & Canada)")
+ assert_equal "Central Time (US & Canada)", Time.zone.name
+ end
+
+ test "resets auxiliary class via callback" do
+ Current.person = Person.new("David", "Central Time (US & Canada)")
+ assert_equal "Central Time (US & Canada)", Time.zone.name
+
+ Current.reset
+ assert_equal "UTC", Time.zone.name
+ end
+
+ test "set attribute only via scope" do
+ Current.world = "world/1"
+
+ Current.set(world: "world/2") do
+ assert_equal "world/2", Current.world
+ end
+
+ assert_equal "world/1", Current.world
+ end
+
+ test "set multiple attributes" do
+ Current.world = "world/1"
+ Current.account = "account/1"
+
+ Current.set(world: "world/2", account: "account/2") do
+ assert_equal "world/2", Current.world
+ assert_equal "account/2", Current.account
+ end
+
+ assert_equal "world/1", Current.world
+ assert_equal "account/1", Current.account
+ end
+
+ test "delegation" do
+ Current.person = Person.new("David", "Central Time (US & Canada)")
+ assert_equal "Central Time (US & Canada)", Current.time_zone
+ assert_equal "Central Time (US & Canada)", Current.instance.time_zone
+ end
+
+ test "all methods forward to the instance" do
+ Current.person = Person.new("David", "Central Time (US & Canada)")
+ assert_equal "David, in Central Time (US & Canada)", Current.intro
+ assert_equal "David, in Central Time (US & Canada)", Current.instance.intro
+ end
+end
diff --git a/railties/test/application/current_attributes_integration_test.rb b/railties/test/application/current_attributes_integration_test.rb
new file mode 100644
index 0000000000..eea2a791a2
--- /dev/null
+++ b/railties/test/application/current_attributes_integration_test.rb
@@ -0,0 +1,88 @@
+require "isolation/abstract_unit"
+require "rack/test"
+
+class CurrentAttributesIntegrationTest < ActiveSupport::TestCase
+ include ActiveSupport::Testing::Isolation
+ include Rack::Test::Methods
+
+ setup do
+ build_app
+
+ app_file "app/services/current.rb", <<-RUBY
+ class Current < ActiveSupport::CurrentAttributes
+ attribute :customer
+
+ resets { Time.zone = "UTC" }
+
+ def customer=(customer)
+ super
+ Time.zone = customer.try(:time_zone)
+ end
+ end
+ RUBY
+
+ app_file "app/models/customer.rb", <<-RUBY
+ class Customer < Struct.new(:name)
+ def time_zone
+ "Copenhagen"
+ end
+ end
+ RUBY
+
+ app_file "config/routes.rb", <<-RUBY
+ Rails.application.routes.draw do
+ get "/customers/:action", controller: :customers
+ end
+ RUBY
+
+ app_file "app/controllers/customers_controller.rb", <<-RUBY
+ class CustomersController < ApplicationController
+ def set_current_customer
+ Current.customer = Customer.new("david")
+ render :index
+ end
+
+ def set_no_customer
+ render :index
+ end
+ end
+ RUBY
+
+ app_file "app/views/customers/index.html.erb", <<-RUBY
+ <%= Current.customer.try(:name) || 'noone' %>,<%= Time.zone.name %>
+ RUBY
+
+ app_file "app/executor_intercept.rb", <<-RUBY
+ check_state = -> { puts [ Current.customer.try(:name) || "noone", Time.zone.name ].join(",") }
+
+ check_state.call
+
+ Rails.application.executor.wrap do
+ Current.customer = Customer.new("david")
+ check_state.call
+ end
+
+ check_state.call
+ RUBY
+
+ require "#{app_path}/config/environment"
+ end
+
+ teardown :teardown_app
+
+ test "current customer is assigned and cleared" do
+ get "/customers/set_current_customer"
+ assert_equal 200, last_response.status
+ assert_match(/david,Copenhagen/, last_response.body)
+
+ get "/customers/set_no_customer"
+ assert_equal 200, last_response.status
+ assert_match(/noone,UTC/, last_response.body)
+ end
+
+ test "resets after execution" do
+ Dir.chdir(app_path) do
+ assert_equal "noone,UTC\ndavid,Copenhagen\nnoone,UTC\n", `bin/rails runner app/executor_intercept.rb`
+ end
+ end
+end