mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
ActiveSupport::CurrentAttributes provides a thread-isolated attributes singleton (#29180)
* Add ActiveSupport::CurrentAttributes to provide a thread-isolated attributes singleton * Need to require first * Move stubs into test namespace. Thus they won't conflict with other Current and Person stubs. * End of the line for you, whitespace! * Support super in attribute methods. Define instance level accessors in an included module such that `super` in an overriden accessor works, akin to Active Model. * Spare users the manual require. Follow the example of concerns, autoload in the top level Active Support file. * Add bidelegation support * Rename #expose to #set. Simpler, clearer * Automatically reset every instance. Skips the need for users to actively embed something that resets their CurrentAttributes instances. * Fix test name; add tangible name value when blank. * Try to ensure we run after a request as well. * Delegate all missing methods to the instance This allows regular `delegate` to serve, so we don't need bidelegate. * Properly test resetting after execution cycle. Also remove the stale puts debugging. * Update documentation to match new autoreset
This commit is contained in:
parent
b404764e15
commit
24a864437e
6 changed files with 385 additions and 0 deletions
|
@ -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
|
||||
|
|
|
@ -32,6 +32,7 @@ module ActiveSupport
|
|||
extend ActiveSupport::Autoload
|
||||
|
||||
autoload :Concern
|
||||
autoload :CurrentAttributes
|
||||
autoload :Dependencies
|
||||
autoload :DescendantsTracker
|
||||
autoload :ExecutionWrapper
|
||||
|
|
190
activesupport/lib/active_support/current_attributes.rb
Normal file
190
activesupport/lib/active_support/current_attributes.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
96
activesupport/test/current_attributes_test.rb
Normal file
96
activesupport/test/current_attributes_test.rb
Normal file
|
@ -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
|
|
@ -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
|
Loading…
Reference in a new issue