New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ActiveSupport::CurrentAttributes provides a thread-isolated attributes singleton #29180

Merged
merged 18 commits into from May 26, 2017
Jump to file or symbol
Failed to load files and symbols.
+385 −0
Diff settings

Always

Just for now

@@ -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
@@ -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)

This comment has been minimized.

@kaspth

kaspth May 25, 2017

Member

@matthewd I can't figure out if I should clear the list here too. In testing with a generated Rails 5.2 app the array never seemed to accumulate instances, so I'm guessing they're dropped between requests through the executor somehow.

@kaspth

kaspth May 25, 2017

Member

@matthewd I can't figure out if I should clear the list here too. In testing with a generated Rails 5.2 app the array never seemed to accumulate instances, so I'm guessing they're dropped between requests through the executor somehow.

This comment has been minimized.

@kaspth

kaspth May 26, 2017

Member

We don't need to clear, since the Thread.current stash for the one instance isn't nulled out too. The instances are meant to be kept around and reset.

@kaspth

kaspth May 26, 2017

Member

We don't need to clear, since the Thread.current stash for the one instance isn't nulled out too. The instances are meant to be kept around and 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 = {}

This comment has been minimized.

@kaspth

kaspth May 25, 2017

Member

@dhh the assignment here means that reset's return value is that empty hash. I found it a little odd when running it from the console. If that's okay to you, I won't touch it.

@kaspth

kaspth May 25, 2017

Member

@dhh the assignment here means that reset's return value is that empty hash. I found it a little odd when running it from the console. If that's okay to you, I won't touch it.

This comment has been minimized.

@dhh

dhh May 26, 2017

Member

Doesn't bother me. We make no promises about the return value. Although, I suppose we could return self so you can chain it. But don't have a use case for that.

@dhh

dhh May 26, 2017

Member

Doesn't bother me. We make no promises about the return value. Although, I suppose we could return self so you can chain it. But don't have a use case for that.

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

This comment has been minimized.

@bogdanvlviv

bogdanvlviv May 26, 2017

Contributor
-keys.collect { |key| [ key, public_send(key) ] }.to_h
+keys.collect { |key, _| [ key, public_send(key) ] }.to_h
@bogdanvlviv

bogdanvlviv May 26, 2017

Contributor
-keys.collect { |key| [ key, public_send(key) ] }.to_h
+keys.collect { |key, _| [ key, public_send(key) ] }.to_h

This comment has been minimized.

@dhh

dhh May 26, 2017

Member

Why? We're iterating over just an array of the keys, not a full hash.

@dhh

dhh May 26, 2017

Member

Why? We're iterating over just an array of the keys, not a full hash.

This comment has been minimized.

end
end
end
@@ -7,6 +7,11 @@ class Railtie < Rails::Railtie # :nodoc:
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
@@ -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
Oops, something went wrong.
ProTip! Use n and p to navigate between commits in a pull request.