Permalink
Show file tree
Hide file tree
1 comment
on commit
sign in to comment.
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
ActiveSupport::CurrentAttributes provides a thread-isolated attribute…
…s 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
- Loading branch information
Showing
6 changed files
with
385 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.
This comment has been hidden.
Sorry, something went wrong.