diff options
Diffstat (limited to 'activesupport/lib')
242 files changed, 23302 insertions, 0 deletions
diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb new file mode 100644 index 0000000000..a4fb697669 --- /dev/null +++ b/activesupport/lib/active_support.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +#-- +# Copyright (c) 2005-2018 David Heinemeier Hansson +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +#++ + +require "securerandom" +require "active_support/dependencies/autoload" +require "active_support/version" +require "active_support/logger" +require "active_support/lazy_load_hooks" +require "active_support/core_ext/date_and_time/compatibility" + +module ActiveSupport + extend ActiveSupport::Autoload + + autoload :Concern + autoload :CurrentAttributes + autoload :Dependencies + autoload :DescendantsTracker + autoload :ExecutionWrapper + autoload :Executor + autoload :FileUpdateChecker + autoload :EventedFileUpdateChecker + autoload :LogSubscriber + autoload :Notifications + autoload :Reloader + + eager_autoload do + autoload :BacktraceCleaner + autoload :ProxyObject + autoload :Benchmarkable + autoload :Cache + autoload :Callbacks + autoload :Configurable + autoload :Deprecation + autoload :Digest + autoload :Gzip + autoload :Inflector + autoload :JSON + autoload :KeyGenerator + autoload :MessageEncryptor + autoload :MessageVerifier + autoload :Multibyte + autoload :NumberHelper + autoload :OptionMerger + autoload :OrderedHash + autoload :OrderedOptions + autoload :StringInquirer + autoload :TaggedLogging + autoload :XmlMini + autoload :ArrayInquirer + end + + autoload :Rescuable + autoload :SafeBuffer, "active_support/core_ext/string/output_safety" + autoload :TestCase + + def self.eager_load! + super + + NumberHelper.eager_load! + end + + cattr_accessor :test_order # :nodoc: + + def self.to_time_preserves_timezone + DateAndTime::Compatibility.preserve_timezone + end + + def self.to_time_preserves_timezone=(value) + DateAndTime::Compatibility.preserve_timezone = value + end +end + +autoload :I18n, "active_support/i18n" diff --git a/activesupport/lib/active_support/all.rb b/activesupport/lib/active_support/all.rb new file mode 100644 index 0000000000..4adf446af8 --- /dev/null +++ b/activesupport/lib/active_support/all.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/time" +require "active_support/core_ext" diff --git a/activesupport/lib/active_support/array_inquirer.rb b/activesupport/lib/active_support/array_inquirer.rb new file mode 100644 index 0000000000..b2b9e9c0b7 --- /dev/null +++ b/activesupport/lib/active_support/array_inquirer.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module ActiveSupport + # Wrapping an array in an +ArrayInquirer+ gives a friendlier way to check + # its string-like contents: + # + # variants = ActiveSupport::ArrayInquirer.new([:phone, :tablet]) + # + # variants.phone? # => true + # variants.tablet? # => true + # variants.desktop? # => false + class ArrayInquirer < Array + # Passes each element of +candidates+ collection to ArrayInquirer collection. + # The method returns true if any element from the ArrayInquirer collection + # is equal to the stringified or symbolized form of any element in the +candidates+ collection. + # + # If +candidates+ collection is not given, method returns true. + # + # variants = ActiveSupport::ArrayInquirer.new([:phone, :tablet]) + # + # variants.any? # => true + # variants.any?(:phone, :tablet) # => true + # variants.any?('phone', 'desktop') # => true + # variants.any?(:desktop, :watch) # => false + def any?(*candidates) + if candidates.none? + super + else + candidates.any? do |candidate| + include?(candidate.to_sym) || include?(candidate.to_s) + end + end + end + + private + def respond_to_missing?(name, include_private = false) + (name[-1] == "?") || super + end + + def method_missing(name, *args) + if name[-1] == "?" + any?(name[0..-2]) + else + super + end + end + end +end diff --git a/activesupport/lib/active_support/backtrace_cleaner.rb b/activesupport/lib/active_support/backtrace_cleaner.rb new file mode 100644 index 0000000000..62973eca58 --- /dev/null +++ b/activesupport/lib/active_support/backtrace_cleaner.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module ActiveSupport + # Backtraces often include many lines that are not relevant for the context + # under review. This makes it hard to find the signal amongst the backtrace + # noise, and adds debugging time. With a BacktraceCleaner, filters and + # silencers are used to remove the noisy lines, so that only the most relevant + # lines remain. + # + # Filters are used to modify lines of data, while silencers are used to remove + # lines entirely. The typical filter use case is to remove lengthy path + # information from the start of each line, and view file paths relevant to the + # app directory instead of the file system root. The typical silencer use case + # is to exclude the output of a noisy library from the backtrace, so that you + # can focus on the rest. + # + # bc = ActiveSupport::BacktraceCleaner.new + # bc.add_filter { |line| line.gsub(Rails.root.to_s, '') } # strip the Rails.root prefix + # bc.add_silencer { |line| line =~ /puma|rubygems/ } # skip any lines from puma or rubygems + # bc.clean(exception.backtrace) # perform the cleanup + # + # To reconfigure an existing BacktraceCleaner (like the default one in Rails) + # and show as much data as possible, you can always call + # <tt>BacktraceCleaner#remove_silencers!</tt>, which will restore the + # backtrace to a pristine state. If you need to reconfigure an existing + # BacktraceCleaner so that it does not filter or modify the paths of any lines + # of the backtrace, you can call <tt>BacktraceCleaner#remove_filters!</tt> + # These two methods will give you a completely untouched backtrace. + # + # Inspired by the Quiet Backtrace gem by thoughtbot. + class BacktraceCleaner + def initialize + @filters, @silencers = [], [] + add_gem_filter + add_gem_silencer + add_stdlib_silencer + end + + # Returns the backtrace after all filters and silencers have been run + # against it. Filters run first, then silencers. + def clean(backtrace, kind = :silent) + filtered = filter_backtrace(backtrace) + + case kind + when :silent + silence(filtered) + when :noise + noise(filtered) + else + filtered + end + end + alias :filter :clean + + # Adds a filter from the block provided. Each line in the backtrace will be + # mapped against this filter. + # + # # Will turn "/my/rails/root/app/models/person.rb" into "/app/models/person.rb" + # backtrace_cleaner.add_filter { |line| line.gsub(Rails.root, '') } + def add_filter(&block) + @filters << block + end + + # Adds a silencer from the block provided. If the silencer returns +true+ + # for a given line, it will be excluded from the clean backtrace. + # + # # Will reject all lines that include the word "puma", like "/gems/puma/server.rb" or "/app/my_puma_server/rb" + # backtrace_cleaner.add_silencer { |line| line =~ /puma/ } + def add_silencer(&block) + @silencers << block + end + + # Removes all silencers, but leaves in the filters. Useful if your + # context of debugging suddenly expands as you suspect a bug in one of + # the libraries you use. + def remove_silencers! + @silencers = [] + end + + # Removes all filters, but leaves in the silencers. Useful if you suddenly + # need to see entire filepaths in the backtrace that you had already + # filtered out. + def remove_filters! + @filters = [] + end + + private + + FORMATTED_GEMS_PATTERN = /\A[^\/]+ \([\w.]+\) / + + def add_gem_filter + gems_paths = (Gem.path | [Gem.default_dir]).map { |p| Regexp.escape(p) } + return if gems_paths.empty? + + gems_regexp = %r{(#{gems_paths.join('|')})/(bundler/)?gems/([^/]+)-([\w.]+)/(.*)} + gems_result = '\3 (\4) \5' + add_filter { |line| line.sub(gems_regexp, gems_result) } + end + + def add_gem_silencer + add_silencer { |line| FORMATTED_GEMS_PATTERN.match?(line) } + end + + def add_stdlib_silencer + add_silencer { |line| line.start_with?(RbConfig::CONFIG["rubylibdir"]) } + end + + def filter_backtrace(backtrace) + @filters.each do |f| + backtrace = backtrace.map { |line| f.call(line) } + end + + backtrace + end + + def silence(backtrace) + @silencers.each do |s| + backtrace = backtrace.reject { |line| s.call(line) } + end + + backtrace + end + + def noise(backtrace) + backtrace - silence(backtrace) + end + end +end diff --git a/activesupport/lib/active_support/benchmarkable.rb b/activesupport/lib/active_support/benchmarkable.rb new file mode 100644 index 0000000000..f481d68198 --- /dev/null +++ b/activesupport/lib/active_support/benchmarkable.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "active_support/core_ext/benchmark" +require "active_support/core_ext/hash/keys" + +module ActiveSupport + module Benchmarkable + # Allows you to measure the execution time of a block in a template and + # records the result to the log. Wrap this block around expensive operations + # or possible bottlenecks to get a time reading for the operation. For + # example, let's say you thought your file processing method was taking too + # long; you could wrap it in a benchmark block. + # + # <% benchmark 'Process data files' do %> + # <%= expensive_files_operation %> + # <% end %> + # + # That would add something like "Process data files (345.2ms)" to the log, + # which you can then use to compare timings when optimizing your code. + # + # You may give an optional logger level (<tt>:debug</tt>, <tt>:info</tt>, + # <tt>:warn</tt>, <tt>:error</tt>) as the <tt>:level</tt> option. The + # default logger level value is <tt>:info</tt>. + # + # <% benchmark 'Low-level files', level: :debug do %> + # <%= lowlevel_files_operation %> + # <% end %> + # + # Finally, you can pass true as the third argument to silence all log + # activity (other than the timing information) from inside the block. This + # is great for boiling down a noisy block to just a single statement that + # produces one log line: + # + # <% benchmark 'Process data files', level: :info, silence: true do %> + # <%= expensive_and_chatty_files_operation %> + # <% end %> + def benchmark(message = "Benchmarking", options = {}) + if logger + options.assert_valid_keys(:level, :silence) + options[:level] ||= :info + + result = nil + ms = Benchmark.ms { result = options[:silence] ? logger.silence { yield } : yield } + logger.send(options[:level], "%s (%.1fms)" % [ message, ms ]) + result + else + yield + end + end + end +end diff --git a/activesupport/lib/active_support/builder.rb b/activesupport/lib/active_support/builder.rb new file mode 100644 index 0000000000..3fa7e6b26d --- /dev/null +++ b/activesupport/lib/active_support/builder.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +begin + require "builder" +rescue LoadError => e + $stderr.puts "You don't have builder installed in your application. Please add it to your Gemfile and run bundle install" + raise e +end diff --git a/activesupport/lib/active_support/cache.rb b/activesupport/lib/active_support/cache.rb new file mode 100644 index 0000000000..e8518645d9 --- /dev/null +++ b/activesupport/lib/active_support/cache.rb @@ -0,0 +1,830 @@ +# frozen_string_literal: true + +require "zlib" +require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/array/wrap" +require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/numeric/bytes" +require "active_support/core_ext/numeric/time" +require "active_support/core_ext/object/to_param" +require "active_support/core_ext/string/inflections" + +module ActiveSupport + # See ActiveSupport::Cache::Store for documentation. + module Cache + autoload :FileStore, "active_support/cache/file_store" + autoload :MemoryStore, "active_support/cache/memory_store" + autoload :MemCacheStore, "active_support/cache/mem_cache_store" + autoload :NullStore, "active_support/cache/null_store" + autoload :RedisCacheStore, "active_support/cache/redis_cache_store" + + # These options mean something to all cache implementations. Individual cache + # implementations may support additional options. + UNIVERSAL_OPTIONS = [:namespace, :compress, :compress_threshold, :expires_in, :race_condition_ttl] + + module Strategy + autoload :LocalCache, "active_support/cache/strategy/local_cache" + end + + class << self + # Creates a new Store object according to the given options. + # + # If no arguments are passed to this method, then a new + # ActiveSupport::Cache::MemoryStore object will be returned. + # + # If you pass a Symbol as the first argument, then a corresponding cache + # store class under the ActiveSupport::Cache namespace will be created. + # For example: + # + # ActiveSupport::Cache.lookup_store(:memory_store) + # # => returns a new ActiveSupport::Cache::MemoryStore object + # + # ActiveSupport::Cache.lookup_store(:mem_cache_store) + # # => returns a new ActiveSupport::Cache::MemCacheStore object + # + # Any additional arguments will be passed to the corresponding cache store + # class's constructor: + # + # ActiveSupport::Cache.lookup_store(:file_store, '/tmp/cache') + # # => same as: ActiveSupport::Cache::FileStore.new('/tmp/cache') + # + # If the first argument is not a Symbol, then it will simply be returned: + # + # ActiveSupport::Cache.lookup_store(MyOwnCacheStore.new) + # # => returns MyOwnCacheStore.new + def lookup_store(*store_option) + store, *parameters = *Array.wrap(store_option).flatten + + case store + when Symbol + retrieve_store_class(store).new(*parameters) + when nil + ActiveSupport::Cache::MemoryStore.new + else + store + end + end + + # Expands out the +key+ argument into a key that can be used for the + # cache store. Optionally accepts a namespace, and all keys will be + # scoped within that namespace. + # + # If the +key+ argument provided is an array, or responds to +to_a+, then + # each of elements in the array will be turned into parameters/keys and + # concatenated into a single key. For example: + # + # ActiveSupport::Cache.expand_cache_key([:foo, :bar]) # => "foo/bar" + # ActiveSupport::Cache.expand_cache_key([:foo, :bar], "namespace") # => "namespace/foo/bar" + # + # The +key+ argument can also respond to +cache_key+ or +to_param+. + def expand_cache_key(key, namespace = nil) + expanded_cache_key = (namespace ? "#{namespace}/" : "").dup + + if prefix = ENV["RAILS_CACHE_ID"] || ENV["RAILS_APP_VERSION"] + expanded_cache_key << "#{prefix}/" + end + + expanded_cache_key << retrieve_cache_key(key) + expanded_cache_key + end + + private + def retrieve_cache_key(key) + case + when key.respond_to?(:cache_key_with_version) then key.cache_key_with_version + when key.respond_to?(:cache_key) then key.cache_key + when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param + when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a) + else key.to_param + end.to_s + end + + # Obtains the specified cache store class, given the name of the +store+. + # Raises an error when the store class cannot be found. + def retrieve_store_class(store) + # require_relative cannot be used here because the class might be + # provided by another gem, like redis-activesupport for example. + require "active_support/cache/#{store}" + rescue LoadError => e + raise "Could not find cache store adapter for #{store} (#{e})" + else + ActiveSupport::Cache.const_get(store.to_s.camelize) + end + end + + # An abstract cache store class. There are multiple cache store + # implementations, each having its own additional features. See the classes + # under the ActiveSupport::Cache module, e.g. + # ActiveSupport::Cache::MemCacheStore. MemCacheStore is currently the most + # popular cache store for large production websites. + # + # Some implementations may not support all methods beyond the basic cache + # methods of +fetch+, +write+, +read+, +exist?+, and +delete+. + # + # ActiveSupport::Cache::Store can store any serializable Ruby object. + # + # cache = ActiveSupport::Cache::MemoryStore.new + # + # cache.read('city') # => nil + # cache.write('city', "Duckburgh") + # cache.read('city') # => "Duckburgh" + # + # Keys are always translated into Strings and are case sensitive. When an + # object is specified as a key and has a +cache_key+ method defined, this + # method will be called to define the key. Otherwise, the +to_param+ + # method will be called. Hashes and Arrays can also be used as keys. The + # elements will be delimited by slashes, and the elements within a Hash + # will be sorted by key so they are consistent. + # + # cache.read('city') == cache.read(:city) # => true + # + # Nil values can be cached. + # + # If your cache is on a shared infrastructure, you can define a namespace + # for your cache entries. If a namespace is defined, it will be prefixed on + # to every key. The namespace can be either a static value or a Proc. If it + # is a Proc, it will be invoked when each key is evaluated so that you can + # use application logic to invalidate keys. + # + # cache.namespace = -> { @last_mod_time } # Set the namespace to a variable + # @last_mod_time = Time.now # Invalidate the entire cache by changing namespace + # + # Cached data larger than 1kB are compressed by default. To turn off + # compression, pass <tt>compress: false</tt> to the initializer or to + # individual +fetch+ or +write+ method calls. The 1kB compression + # threshold is configurable with the <tt>:compress_threshold</tt> option, + # specified in bytes. + class Store + cattr_accessor :logger, instance_writer: true + + attr_reader :silence, :options + alias :silence? :silence + + class << self + private + def retrieve_pool_options(options) + {}.tap do |pool_options| + pool_options[:size] = options.delete(:pool_size) if options[:pool_size] + pool_options[:timeout] = options.delete(:pool_timeout) if options[:pool_timeout] + end + end + + def ensure_connection_pool_added! + require "connection_pool" + rescue LoadError => e + $stderr.puts "You don't have connection_pool installed in your application. Please add it to your Gemfile and run bundle install" + raise e + end + end + + # Creates a new cache. The options will be passed to any write method calls + # except for <tt>:namespace</tt> which can be used to set the global + # namespace for the cache. + def initialize(options = nil) + @options = options ? options.dup : {} + end + + # Silences the logger. + def silence! + @silence = true + self + end + + # Silences the logger within a block. + def mute + previous_silence, @silence = defined?(@silence) && @silence, true + yield + ensure + @silence = previous_silence + end + + # Fetches data from the cache, using the given key. If there is data in + # the cache with the given key, then that data is returned. + # + # If there is no such data in the cache (a cache miss), then +nil+ will be + # returned. However, if a block has been passed, that block will be passed + # the key and executed in the event of a cache miss. The return value of the + # block will be written to the cache under the given cache key, and that + # return value will be returned. + # + # cache.write('today', 'Monday') + # cache.fetch('today') # => "Monday" + # + # cache.fetch('city') # => nil + # cache.fetch('city') do + # 'Duckburgh' + # end + # cache.fetch('city') # => "Duckburgh" + # + # You may also specify additional options via the +options+ argument. + # Setting <tt>force: true</tt> forces a cache "miss," meaning we treat + # the cache value as missing even if it's present. Passing a block is + # required when +force+ is true so this always results in a cache write. + # + # cache.write('today', 'Monday') + # cache.fetch('today', force: true) { 'Tuesday' } # => 'Tuesday' + # cache.fetch('today', force: true) # => ArgumentError + # + # The +:force+ option is useful when you're calling some other method to + # ask whether you should force a cache write. Otherwise, it's clearer to + # just call <tt>Cache#write</tt>. + # + # Setting <tt>skip_nil: true</tt> will not cache nil result: + # + # cache.fetch('foo') { nil } + # cache.fetch('bar', skip_nil: true) { nil } + # cache.exist?('foo') # => true + # cache.exist?('bar') # => false + # + # + # Setting <tt>compress: false</tt> disables compression of the cache entry. + # + # Setting <tt>:expires_in</tt> will set an expiration time on the cache. + # All caches support auto-expiring content after a specified number of + # seconds. This value can be specified as an option to the constructor + # (in which case all entries will be affected), or it can be supplied to + # the +fetch+ or +write+ method to effect just one entry. + # + # cache = ActiveSupport::Cache::MemoryStore.new(expires_in: 5.minutes) + # cache.write(key, value, expires_in: 1.minute) # Set a lower value for one entry + # + # Setting <tt>:version</tt> verifies the cache stored under <tt>name</tt> + # is of the same version. nil is returned on mismatches despite contents. + # This feature is used to support recyclable cache keys. + # + # Setting <tt>:race_condition_ttl</tt> is very useful in situations where + # a cache entry is used very frequently and is under heavy load. If a + # cache expires and due to heavy load several different processes will try + # to read data natively and then they all will try to write to cache. To + # avoid that case the first process to find an expired cache entry will + # bump the cache expiration time by the value set in <tt>:race_condition_ttl</tt>. + # Yes, this process is extending the time for a stale value by another few + # seconds. Because of extended life of the previous cache, other processes + # will continue to use slightly stale data for a just a bit longer. In the + # meantime that first process will go ahead and will write into cache the + # new value. After that all the processes will start getting the new value. + # The key is to keep <tt>:race_condition_ttl</tt> small. + # + # If the process regenerating the entry errors out, the entry will be + # regenerated after the specified number of seconds. Also note that the + # life of stale cache is extended only if it expired recently. Otherwise + # a new value is generated and <tt>:race_condition_ttl</tt> does not play + # any role. + # + # # Set all values to expire after one minute. + # cache = ActiveSupport::Cache::MemoryStore.new(expires_in: 1.minute) + # + # cache.write('foo', 'original value') + # val_1 = nil + # val_2 = nil + # sleep 60 + # + # Thread.new do + # val_1 = cache.fetch('foo', race_condition_ttl: 10.seconds) do + # sleep 1 + # 'new value 1' + # end + # end + # + # Thread.new do + # val_2 = cache.fetch('foo', race_condition_ttl: 10.seconds) do + # 'new value 2' + # end + # end + # + # cache.fetch('foo') # => "original value" + # sleep 10 # First thread extended the life of cache by another 10 seconds + # cache.fetch('foo') # => "new value 1" + # val_1 # => "new value 1" + # val_2 # => "original value" + # + # Other options will be handled by the specific cache store implementation. + # Internally, #fetch calls #read_entry, and calls #write_entry on a cache + # miss. +options+ will be passed to the #read and #write calls. + # + # For example, MemCacheStore's #write method supports the +:raw+ + # option, which tells the memcached server to store all values as strings. + # We can use this option with #fetch too: + # + # cache = ActiveSupport::Cache::MemCacheStore.new + # cache.fetch("foo", force: true, raw: true) do + # :bar + # end + # cache.fetch('foo') # => "bar" + def fetch(name, options = nil) + if block_given? + options = merged_options(options) + key = normalize_key(name, options) + + entry = nil + instrument(:read, name, options) do |payload| + cached_entry = read_entry(key, options) unless options[:force] + entry = handle_expired_entry(cached_entry, key, options) + entry = nil if entry && entry.mismatched?(normalize_version(name, options)) + payload[:super_operation] = :fetch if payload + payload[:hit] = !!entry if payload + end + + if entry + get_entry_value(entry, name, options) + else + save_block_result_to_cache(name, options) { |_name| yield _name } + end + elsif options && options[:force] + raise ArgumentError, "Missing block: Calling `Cache#fetch` with `force: true` requires a block." + else + read(name, options) + end + end + + # Reads data from the cache, using the given key. If there is data in + # the cache with the given key, then that data is returned. Otherwise, + # +nil+ is returned. + # + # Note, if data was written with the <tt>:expires_in</tt> or + # <tt>:version</tt> options, both of these conditions are applied before + # the data is returned. + # + # Options are passed to the underlying cache implementation. + def read(name, options = nil) + options = merged_options(options) + key = normalize_key(name, options) + version = normalize_version(name, options) + + instrument(:read, name, options) do |payload| + entry = read_entry(key, options) + + if entry + if entry.expired? + delete_entry(key, options) + payload[:hit] = false if payload + nil + elsif entry.mismatched?(version) + payload[:hit] = false if payload + nil + else + payload[:hit] = true if payload + entry.value + end + else + payload[:hit] = false if payload + nil + end + end + end + + # Reads multiple values at once from the cache. Options can be passed + # in the last argument. + # + # Some cache implementation may optimize this method. + # + # Returns a hash mapping the names provided to the values found. + def read_multi(*names) + options = names.extract_options! + options = merged_options(options) + + instrument :read_multi, names, options do |payload| + read_multi_entries(names, options).tap do |results| + payload[:hits] = results.keys + end + end + end + + # Cache Storage API to write multiple values at once. + def write_multi(hash, options = nil) + options = merged_options(options) + + instrument :write_multi, hash, options do |payload| + entries = hash.each_with_object({}) do |(name, value), memo| + memo[normalize_key(name, options)] = Entry.new(value, options.merge(version: normalize_version(name, options))) + end + + write_multi_entries entries, options + end + end + + # Fetches data from the cache, using the given keys. If there is data in + # the cache with the given keys, then that data is returned. Otherwise, + # the supplied block is called for each key for which there was no data, + # and the result will be written to the cache and returned. + # Therefore, you need to pass a block that returns the data to be written + # to the cache. If you do not want to write the cache when the cache is + # not found, use #read_multi. + # + # Returns a hash with the data for each of the names. For example: + # + # cache.write("bim", "bam") + # cache.fetch_multi("bim", "unknown_key") do |key| + # "Fallback value for key: #{key}" + # end + # # => { "bim" => "bam", + # # "unknown_key" => "Fallback value for key: unknown_key" } + # + # Options are passed to the underlying cache implementation. For example: + # + # cache.fetch_multi("fizz", expires_in: 5.seconds) do |key| + # "buzz" + # end + # # => {"fizz"=>"buzz"} + # cache.read("fizz") + # # => "buzz" + # sleep(6) + # cache.read("fizz") + # # => nil + def fetch_multi(*names) + raise ArgumentError, "Missing block: `Cache#fetch_multi` requires a block." unless block_given? + + options = names.extract_options! + options = merged_options(options) + + instrument :read_multi, names, options do |payload| + read_multi_entries(names, options).tap do |results| + payload[:hits] = results.keys + payload[:super_operation] = :fetch_multi + + writes = {} + + (names - results.keys).each do |name| + results[name] = writes[name] = yield(name) + end + + write_multi writes, options + end + end + end + + # Writes the value to the cache, with the key. + # + # Options are passed to the underlying cache implementation. + def write(name, value, options = nil) + options = merged_options(options) + + instrument(:write, name, options) do + entry = Entry.new(value, options.merge(version: normalize_version(name, options))) + write_entry(normalize_key(name, options), entry, options) + end + end + + # Deletes an entry in the cache. Returns +true+ if an entry is deleted. + # + # Options are passed to the underlying cache implementation. + def delete(name, options = nil) + options = merged_options(options) + + instrument(:delete, name) do + delete_entry(normalize_key(name, options), options) + end + end + + # Returns +true+ if the cache contains an entry for the given key. + # + # Options are passed to the underlying cache implementation. + def exist?(name, options = nil) + options = merged_options(options) + + instrument(:exist?, name) do + entry = read_entry(normalize_key(name, options), options) + (entry && !entry.expired? && !entry.mismatched?(normalize_version(name, options))) || false + end + end + + # Deletes all entries with keys matching the pattern. + # + # Options are passed to the underlying cache implementation. + # + # All implementations may not support this method. + def delete_matched(matcher, options = nil) + raise NotImplementedError.new("#{self.class.name} does not support delete_matched") + end + + # Increments an integer value in the cache. + # + # Options are passed to the underlying cache implementation. + # + # All implementations may not support this method. + def increment(name, amount = 1, options = nil) + raise NotImplementedError.new("#{self.class.name} does not support increment") + end + + # Decrements an integer value in the cache. + # + # Options are passed to the underlying cache implementation. + # + # All implementations may not support this method. + def decrement(name, amount = 1, options = nil) + raise NotImplementedError.new("#{self.class.name} does not support decrement") + end + + # Cleanups the cache by removing expired entries. + # + # Options are passed to the underlying cache implementation. + # + # All implementations may not support this method. + def cleanup(options = nil) + raise NotImplementedError.new("#{self.class.name} does not support cleanup") + end + + # Clears the entire cache. Be careful with this method since it could + # affect other processes if shared cache is being used. + # + # The options hash is passed to the underlying cache implementation. + # + # All implementations may not support this method. + def clear(options = nil) + raise NotImplementedError.new("#{self.class.name} does not support clear") + end + + private + # Adds the namespace defined in the options to a pattern designed to + # match keys. Implementations that support delete_matched should call + # this method to translate a pattern that matches names into one that + # matches namespaced keys. + def key_matcher(pattern, options) # :doc: + prefix = options[:namespace].is_a?(Proc) ? options[:namespace].call : options[:namespace] + if prefix + source = pattern.source + if source.start_with?("^") + source = source[1, source.length] + else + source = ".*#{source[0, source.length]}" + end + Regexp.new("^#{Regexp.escape(prefix)}:#{source}", pattern.options) + else + pattern + end + end + + # Reads an entry from the cache implementation. Subclasses must implement + # this method. + def read_entry(key, options) + raise NotImplementedError.new + end + + # Writes an entry to the cache implementation. Subclasses must implement + # this method. + def write_entry(key, entry, options) + raise NotImplementedError.new + end + + # Reads multiple entries from the cache implementation. Subclasses MAY + # implement this method. + def read_multi_entries(names, options) + results = {} + names.each do |name| + key = normalize_key(name, options) + version = normalize_version(name, options) + entry = read_entry(key, options) + + if entry + if entry.expired? + delete_entry(key, options) + elsif entry.mismatched?(version) + # Skip mismatched versions + else + results[name] = entry.value + end + end + end + results + end + + # Writes multiple entries to the cache implementation. Subclasses MAY + # implement this method. + def write_multi_entries(hash, options) + hash.each do |key, entry| + write_entry key, entry, options + end + end + + # Deletes an entry from the cache implementation. Subclasses must + # implement this method. + def delete_entry(key, options) + raise NotImplementedError.new + end + + # Merges the default options with ones specific to a method call. + def merged_options(call_options) + if call_options + if options.empty? + call_options + else + options.merge(call_options) + end + else + options + end + end + + # Expands and namespaces the cache key. May be overridden by + # cache stores to do additional normalization. + def normalize_key(key, options = nil) + namespace_key expanded_key(key), options + end + + # Prefix the key with a namespace string: + # + # namespace_key 'foo', namespace: 'cache' + # # => 'cache:foo' + # + # With a namespace block: + # + # namespace_key 'foo', namespace: -> { 'cache' } + # # => 'cache:foo' + def namespace_key(key, options = nil) + options = merged_options(options) + namespace = options[:namespace] + + if namespace.respond_to?(:call) + namespace = namespace.call + end + + if namespace + "#{namespace}:#{key}" + else + key + end + end + + # Expands key to be a consistent string value. Invokes +cache_key+ if + # object responds to +cache_key+. Otherwise, +to_param+ method will be + # called. If the key is a Hash, then keys will be sorted alphabetically. + def expanded_key(key) + return key.cache_key.to_s if key.respond_to?(:cache_key) + + case key + when Array + if key.size > 1 + key = key.collect { |element| expanded_key(element) } + else + key = expanded_key(key.first) + end + when Hash + key = key.sort_by { |k, _| k.to_s }.collect { |k, v| "#{k}=#{v}" } + end + + key.to_param + end + + def normalize_version(key, options = nil) + (options && options[:version].try(:to_param)) || expanded_version(key) + end + + def expanded_version(key) + case + when key.respond_to?(:cache_version) then key.cache_version.to_param + when key.is_a?(Array) then key.map { |element| expanded_version(element) }.compact.to_param + when key.respond_to?(:to_a) then expanded_version(key.to_a) + end + end + + def instrument(operation, key, options = nil) + log { "Cache #{operation}: #{normalize_key(key, options)}#{options.blank? ? "" : " (#{options.inspect})"}" } + + payload = { key: key } + payload.merge!(options) if options.is_a?(Hash) + ActiveSupport::Notifications.instrument("cache_#{operation}.active_support", payload) { yield(payload) } + end + + def log + return unless logger && logger.debug? && !silence? + logger.debug(yield) + end + + def handle_expired_entry(entry, key, options) + if entry && entry.expired? + race_ttl = options[:race_condition_ttl].to_i + if (race_ttl > 0) && (Time.now.to_f - entry.expires_at <= race_ttl) + # When an entry has a positive :race_condition_ttl defined, put the stale entry back into the cache + # for a brief period while the entry is being recalculated. + entry.expires_at = Time.now + race_ttl + write_entry(key, entry, expires_in: race_ttl * 2) + else + delete_entry(key, options) + end + entry = nil + end + entry + end + + def get_entry_value(entry, name, options) + instrument(:fetch_hit, name, options) { } + entry.value + end + + def save_block_result_to_cache(name, options) + result = instrument(:generate, name, options) do + yield(name) + end + + write(name, result, options) unless result.nil? && options[:skip_nil] + result + end + end + + # This class is used to represent cache entries. Cache entries have a value, an optional + # expiration time, and an optional version. The expiration time is used to support the :race_condition_ttl option + # on the cache. The version is used to support the :version option on the cache for rejecting + # mismatches. + # + # Since cache entries in most instances will be serialized, the internals of this class are highly optimized + # using short instance variable names that are lazily defined. + class Entry # :nodoc: + attr_reader :version + + DEFAULT_COMPRESS_LIMIT = 1.kilobyte + + # Creates a new cache entry for the specified value. Options supported are + # +:compress+, +:compress_threshold+, +:version+ and +:expires_in+. + def initialize(value, compress: true, compress_threshold: DEFAULT_COMPRESS_LIMIT, version: nil, expires_in: nil, **) + @value = value + @version = version + @created_at = Time.now.to_f + @expires_in = expires_in && expires_in.to_f + + compress!(compress_threshold) if compress + end + + def value + compressed? ? uncompress(@value) : @value + end + + def mismatched?(version) + @version && version && @version != version + end + + # Checks if the entry is expired. The +expires_in+ parameter can override + # the value set when the entry was created. + def expired? + @expires_in && @created_at + @expires_in <= Time.now.to_f + end + + def expires_at + @expires_in ? @created_at + @expires_in : nil + end + + def expires_at=(value) + if value + @expires_in = value.to_f - @created_at + else + @expires_in = nil + end + end + + # Returns the size of the cached value. This could be less than + # <tt>value.size</tt> if the data is compressed. + def size + case value + when NilClass + 0 + when String + @value.bytesize + else + @s ||= Marshal.dump(@value).bytesize + end + end + + # Duplicates the value in a class. This is used by cache implementations that don't natively + # serialize entries to protect against accidental cache modifications. + def dup_value! + if @value && !compressed? && !(@value.is_a?(Numeric) || @value == true || @value == false) + if @value.is_a?(String) + @value = @value.dup + else + @value = Marshal.load(Marshal.dump(@value)) + end + end + end + + private + def compress!(compress_threshold) + case @value + when nil, true, false, Numeric + uncompressed_size = 0 + when String + uncompressed_size = @value.bytesize + else + serialized = Marshal.dump(@value) + uncompressed_size = serialized.bytesize + end + + if uncompressed_size >= compress_threshold + serialized ||= Marshal.dump(@value) + compressed = Zlib::Deflate.deflate(serialized) + + if compressed.bytesize < uncompressed_size + @value = compressed + @compressed = true + end + end + end + + def compressed? + defined?(@compressed) + end + + def uncompress(value) + Marshal.load(Zlib::Inflate.inflate(value)) + end + end + end +end diff --git a/activesupport/lib/active_support/cache/file_store.rb b/activesupport/lib/active_support/cache/file_store.rb new file mode 100644 index 0000000000..de1fb1886c --- /dev/null +++ b/activesupport/lib/active_support/cache/file_store.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require "active_support/core_ext/marshal" +require "active_support/core_ext/file/atomic" +require "active_support/core_ext/string/conversions" +require "uri/common" + +module ActiveSupport + module Cache + # A cache store implementation which stores everything on the filesystem. + # + # FileStore implements the Strategy::LocalCache strategy which implements + # an in-memory cache inside of a block. + class FileStore < Store + prepend Strategy::LocalCache + attr_reader :cache_path + + 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) + super(options) + @cache_path = cache_path.to_s + end + + # Advertise cache versioning support. + def self.supports_cache_versioning? + true + end + + # Deletes all items from the cache. In this case it deletes all the entries in the specified + # 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) + FileUtils.rm_r(root_dirs.collect { |f| File.join(cache_path, f) }) + rescue Errno::ENOENT + end + + # Preemptively iterates through all stored keys and removes the ones which have expired. + def cleanup(options = nil) + options = merged_options(options) + search_dir(cache_path) do |fname| + entry = read_entry(fname, options) + delete_entry(fname, options) if entry && entry.expired? + end + end + + # Increments an already existing integer value that is stored in the cache. + # If the key is not found nothing is done. + def increment(name, amount = 1, options = nil) + modify_value(name, amount, options) + end + + # Decrements an already existing integer value that is stored in the cache. + # If the key is not found nothing is done. + def decrement(name, amount = 1, options = nil) + modify_value(name, -amount, options) + end + + def delete_matched(matcher, options = nil) + options = merged_options(options) + instrument(:delete_matched, matcher.inspect) do + matcher = key_matcher(matcher, options) + search_dir(cache_path) do |path| + key = file_path_key(path) + delete_entry(path, options) if key.match(matcher) + end + end + end + + private + + def read_entry(key, options) + if File.exist?(key) + File.open(key) { |f| Marshal.load(f) } + end + rescue => e + logger.error("FileStoreError (#{e}): #{e.message}") if logger + nil + end + + def write_entry(key, entry, options) + return false if options[:unless_exist] && File.exist?(key) + ensure_cache_path(File.dirname(key)) + File.atomic_write(key, cache_path) { |f| Marshal.dump(entry, f) } + true + end + + def delete_entry(key, options) + if File.exist?(key) + begin + File.delete(key) + delete_empty_directories(File.dirname(key)) + true + rescue => e + # Just in case the error was caused by another process deleting the file first. + raise e if File.exist?(key) + false + end + end + end + + # Lock a file for a block so only one process can modify it at a time. + def lock_file(file_name, &block) + if File.exist?(file_name) + File.open(file_name, "r+") do |f| + f.flock File::LOCK_EX + yield + ensure + f.flock File::LOCK_UN + end + else + yield + end + end + + # Translate a key into a file path. + def normalize_key(key, options) + key = super + fname = URI.encode_www_form_component(key) + + if fname.size > FILEPATH_MAX_SIZE + fname = ActiveSupport::Digest.hexdigest(key) + end + + hash = Zlib.adler32(fname) + hash, dir_1 = hash.divmod(0x1000) + dir_2 = hash.modulo(0x1000) + + # Make sure file name doesn't exceed file system limits. + if fname.length < FILENAME_MAX_SIZE + fname_paths = fname + else + fname_paths = [] + begin + fname_paths << fname[0, FILENAME_MAX_SIZE] + fname = fname[FILENAME_MAX_SIZE..-1] + end until fname.blank? + end + + File.join(cache_path, DIR_FORMATTER % dir_1, DIR_FORMATTER % dir_2, fname_paths) + end + + # Translate a file path into a key. + def file_path_key(path) + fname = path[cache_path.to_s.size..-1].split(File::SEPARATOR, 4).last + URI.decode_www_form_component(fname, Encoding::UTF_8) + end + + # 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? + Dir.delete(dir) rescue nil + delete_empty_directories(File.dirname(dir)) + end + end + + # Make sure a file path's directories exist. + def ensure_cache_path(path) + FileUtils.makedirs(path) unless File.exist?(path) + end + + def search_dir(dir, &callback) + return if !File.exist?(dir) + Dir.foreach(dir) do |d| + next if EXCLUDED_DIRS.include?(d) + name = File.join(dir, d) + if File.directory?(name) + search_dir(name, &callback) + else + callback.call name + end + end + end + + # Modifies the amount of an already existing integer value that is stored in the cache. + # If the key is not found nothing is done. + def modify_value(name, amount, options) + file_name = normalize_key(name, options) + + lock_file(file_name) do + options = merged_options(options) + + if num = read(name, options) + num = num.to_i + amount + write(name, num, options) + num + 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/cache/mem_cache_store.rb b/activesupport/lib/active_support/cache/mem_cache_store.rb new file mode 100644 index 0000000000..174c784deb --- /dev/null +++ b/activesupport/lib/active_support/cache/mem_cache_store.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +begin + require "dalli" +rescue LoadError => e + $stderr.puts "You don't have dalli installed in your application. Please add it to your Gemfile and run bundle install" + raise e +end + +require "active_support/core_ext/marshal" +require "active_support/core_ext/array/extract_options" + +module ActiveSupport + module Cache + # A cache store implementation which stores data in Memcached: + # https://memcached.org + # + # This is currently the most popular cache store for production websites. + # + # Special features: + # - Clustering and load balancing. One can specify multiple memcached servers, + # and MemCacheStore will load balance between all available servers. If a + # server goes down, then MemCacheStore will ignore it until it comes back up. + # + # MemCacheStore implements the Strategy::LocalCache strategy which implements + # an in-memory cache inside of a block. + class MemCacheStore < Store + # Provide support for raw values in the local cache strategy. + module LocalCacheWithRaw # :nodoc: + private + def read_entry(key, options) + entry = super + if options[:raw] && local_cache && entry + entry = deserialize_entry(entry.value) + end + entry + end + + def write_entry(key, entry, options) + if options[:raw] && local_cache + raw_entry = Entry.new(entry.value.to_s) + raw_entry.expires_at = entry.expires_at + super(key, raw_entry, options) + else + super + end + end + end + + # Advertise cache versioning support. + def self.supports_cache_versioning? + true + end + + prepend Strategy::LocalCache + prepend LocalCacheWithRaw + + ESCAPE_KEY_CHARS = /[\x00-\x20%\x7F-\xFF]/n + + # Creates a new Dalli::Client instance with specified addresses and options. + # By default address is equal localhost:11211. + # + # ActiveSupport::Cache::MemCacheStore.build_mem_cache + # # => #<Dalli::Client:0x007f98a47d2028 @servers=["localhost:11211"], @options={}, @ring=nil> + # ActiveSupport::Cache::MemCacheStore.build_mem_cache('localhost:10290') + # # => #<Dalli::Client:0x007f98a47b3a60 @servers=["localhost:10290"], @options={}, @ring=nil> + def self.build_mem_cache(*addresses) # :nodoc: + addresses = addresses.flatten + options = addresses.extract_options! + addresses = ["localhost:11211"] if addresses.empty? + pool_options = retrieve_pool_options(options) + + if pool_options.empty? + Dalli::Client.new(addresses, options) + else + ensure_connection_pool_added! + ConnectionPool.new(pool_options) { Dalli::Client.new(addresses, options.merge(threadsafe: false)) } + end + end + + # Creates a new MemCacheStore object, with the given memcached server + # addresses. Each address is either a host name, or a host-with-port string + # in the form of "host_name:port". For example: + # + # ActiveSupport::Cache::MemCacheStore.new("localhost", "server-downstairs.localnetwork:8229") + # + # If no addresses are specified, then MemCacheStore will connect to + # localhost port 11211 (the default memcached port). + def initialize(*addresses) + addresses = addresses.flatten + options = addresses.extract_options! + super(options) + + unless [String, Dalli::Client, NilClass].include?(addresses.first.class) + raise ArgumentError, "First argument must be an empty array, an array of hosts or a Dalli::Client instance." + end + if addresses.first.is_a?(Dalli::Client) + @data = addresses.first + else + mem_cache_options = options.dup + UNIVERSAL_OPTIONS.each { |name| mem_cache_options.delete(name) } + @data = self.class.build_mem_cache(*(addresses + [mem_cache_options])) + end + end + + # Increment a cached value. This method uses the memcached incr atomic + # operator and can only be used on values written with the :raw option. + # Calling it on a value not stored with :raw will initialize that value + # to zero. + def increment(name, amount = 1, options = nil) + options = merged_options(options) + instrument(:increment, name, amount: amount) do + rescue_error_with nil do + @data.with { |c| c.incr(normalize_key(name, options), amount, options[:expires_in]) } + end + end + end + + # Decrement a cached value. This method uses the memcached decr atomic + # operator and can only be used on values written with the :raw option. + # Calling it on a value not stored with :raw will initialize that value + # to zero. + def decrement(name, amount = 1, options = nil) + options = merged_options(options) + instrument(:decrement, name, amount: amount) do + rescue_error_with nil do + @data.with { |c| c.decr(normalize_key(name, options), amount, options[:expires_in]) } + end + end + end + + # Clear the entire cache on all memcached servers. This method should + # be used with care when shared cache is being used. + def clear(options = nil) + rescue_error_with(nil) { @data.with { |c| c.flush_all } } + end + + # Get the statistics from the memcached servers. + def stats + @data.with { |c| c.stats } + end + + private + # Read an entry from the cache. + def read_entry(key, options) + rescue_error_with(nil) { deserialize_entry(@data.with { |c| c.get(key, options) }) } + end + + # Write an entry to the cache. + def write_entry(key, entry, options) + method = options && options[:unless_exist] ? :add : :set + value = options[:raw] ? entry.value.to_s : entry + expires_in = options[:expires_in].to_i + if expires_in > 0 && !options[:raw] + # Set the memcache expire a few minutes in the future to support race condition ttls on read + expires_in += 5.minutes + end + rescue_error_with false do + @data.with { |c| c.send(method, key, value, expires_in, options) } + end + end + + # Reads multiple entries from the cache implementation. + def read_multi_entries(names, options) + keys_to_names = Hash[names.map { |name| [normalize_key(name, options), name] }] + + raw_values = @data.with { |c| c.get_multi(keys_to_names.keys) } + values = {} + + raw_values.each do |key, value| + entry = deserialize_entry(value) + + unless entry.expired? || entry.mismatched?(normalize_version(keys_to_names[key], options)) + values[keys_to_names[key]] = entry.value + end + end + + values + end + + # Delete an entry from the cache. + def delete_entry(key, options) + rescue_error_with(false) { @data.with { |c| c.delete(key) } } + end + + # Memcache keys are binaries. So we need to force their encoding to binary + # before applying the regular expression to ensure we are escaping all + # characters properly. + def normalize_key(key, options) + key = super.dup + key = key.force_encoding(Encoding::ASCII_8BIT) + key = key.gsub(ESCAPE_KEY_CHARS) { |match| "%#{match.getbyte(0).to_s(16).upcase}" } + key = "#{key[0, 213]}:md5:#{ActiveSupport::Digest.hexdigest(key)}" if key.size > 250 + key + end + + def deserialize_entry(raw_value) + if raw_value + entry = Marshal.load(raw_value) rescue raw_value + entry.is_a?(Entry) ? entry : Entry.new(entry) + end + end + + def rescue_error_with(fallback) + yield + rescue Dalli::DalliError => e + logger.error("DalliError (#{e}): #{e.message}") if logger + fallback + end + end + end +end diff --git a/activesupport/lib/active_support/cache/memory_store.rb b/activesupport/lib/active_support/cache/memory_store.rb new file mode 100644 index 0000000000..106b616529 --- /dev/null +++ b/activesupport/lib/active_support/cache/memory_store.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "monitor" + +module ActiveSupport + module Cache + # A cache store implementation which stores everything into memory in the + # same process. If you're running multiple Ruby on Rails server processes + # (which is the case if you're using Phusion Passenger or puma clustered mode), + # then this means that Rails server process instances won't be able + # to share cache data with each other and this may not be the most + # appropriate cache in that scenario. + # + # This cache has a bounded size specified by the :size options to the + # initializer (default is 32Mb). When the cache exceeds the allotted size, + # a cleanup will occur which tries to prune the cache down to three quarters + # of the maximum size by removing the least recently used entries. + # + # MemoryStore is thread-safe. + class MemoryStore < Store + def initialize(options = nil) + options ||= {} + super(options) + @data = {} + @key_access = {} + @max_size = options[:size] || 32.megabytes + @max_prune_time = options[:max_prune_time] || 2 + @cache_size = 0 + @monitor = Monitor.new + @pruning = false + end + + # Advertise cache versioning support. + def self.supports_cache_versioning? + true + end + + # Delete all data stored in a given cache store. + def clear(options = nil) + synchronize do + @data.clear + @key_access.clear + @cache_size = 0 + end + end + + # Preemptively iterates through all stored keys and removes the ones which have expired. + def cleanup(options = nil) + options = merged_options(options) + instrument(:cleanup, size: @data.size) do + keys = synchronize { @data.keys } + keys.each do |key| + entry = @data[key] + delete_entry(key, options) if entry && entry.expired? + end + end + end + + # To ensure entries fit within the specified memory prune the cache by removing the least + # recently accessed entries. + def prune(target_size, max_time = nil) + return if pruning? + @pruning = true + begin + start_time = Time.now + cleanup + instrument(:prune, target_size, from: @cache_size) do + keys = synchronize { @key_access.keys.sort { |a, b| @key_access[a].to_f <=> @key_access[b].to_f } } + keys.each do |key| + delete_entry(key, options) + return if @cache_size <= target_size || (max_time && Time.now - start_time > max_time) + end + end + ensure + @pruning = false + end + end + + # Returns true if the cache is currently being pruned. + def pruning? + @pruning + end + + # Increment an integer value in the cache. + def increment(name, amount = 1, options = nil) + modify_value(name, amount, options) + end + + # Decrement an integer value in the cache. + def decrement(name, amount = 1, options = nil) + modify_value(name, -amount, options) + end + + # Deletes cache entries if the cache key matches a given pattern. + def delete_matched(matcher, options = nil) + options = merged_options(options) + instrument(:delete_matched, matcher.inspect) do + matcher = key_matcher(matcher, options) + keys = synchronize { @data.keys } + keys.each do |key| + delete_entry(key, options) if key.match(matcher) + end + end + end + + def inspect # :nodoc: + "<##{self.class.name} entries=#{@data.size}, size=#{@cache_size}, options=#{@options.inspect}>" + end + + # Synchronize calls to the cache. This should be called wherever the underlying cache implementation + # is not thread safe. + def synchronize(&block) # :nodoc: + @monitor.synchronize(&block) + end + + private + + PER_ENTRY_OVERHEAD = 240 + + def cached_size(key, entry) + key.to_s.bytesize + entry.size + PER_ENTRY_OVERHEAD + end + + def read_entry(key, options) + entry = @data[key] + synchronize do + if entry + @key_access[key] = Time.now.to_f + else + @key_access.delete(key) + end + end + entry + end + + def write_entry(key, entry, options) + entry.dup_value! + synchronize do + old_entry = @data[key] + return false if @data.key?(key) && options[:unless_exist] + if old_entry + @cache_size -= (old_entry.size - entry.size) + else + @cache_size += cached_size(key, entry) + end + @key_access[key] = Time.now.to_f + @data[key] = entry + prune(@max_size * 0.75, @max_prune_time) if @cache_size > @max_size + true + end + end + + def delete_entry(key, options) + synchronize do + @key_access.delete(key) + entry = @data.delete(key) + @cache_size -= cached_size(key, entry) if entry + !!entry + end + end + + def modify_value(name, amount, options) + synchronize do + options = merged_options(options) + if num = read(name, options) + num = num.to_i + amount + write(name, num, options) + num + end + end + end + end + end +end diff --git a/activesupport/lib/active_support/cache/null_store.rb b/activesupport/lib/active_support/cache/null_store.rb new file mode 100644 index 0000000000..8452a28fd8 --- /dev/null +++ b/activesupport/lib/active_support/cache/null_store.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module ActiveSupport + module Cache + # A cache store implementation which doesn't actually store anything. Useful in + # development and test environments where you don't want caching turned on but + # need to go through the caching interface. + # + # This cache does implement the local cache strategy, so values will actually + # be cached inside blocks that utilize this strategy. See + # ActiveSupport::Cache::Strategy::LocalCache for more details. + class NullStore < Store + prepend Strategy::LocalCache + + # Advertise cache versioning support. + def self.supports_cache_versioning? + true + end + + def clear(options = nil) + end + + def cleanup(options = nil) + end + + def increment(name, amount = 1, options = nil) + end + + def decrement(name, amount = 1, options = nil) + end + + def delete_matched(matcher, options = nil) + end + + private + def read_entry(key, options) + end + + def write_entry(key, entry, options) + true + end + + def delete_entry(key, options) + false + end + end + end +end diff --git a/activesupport/lib/active_support/cache/redis_cache_store.rb b/activesupport/lib/active_support/cache/redis_cache_store.rb new file mode 100644 index 0000000000..9a55e49e27 --- /dev/null +++ b/activesupport/lib/active_support/cache/redis_cache_store.rb @@ -0,0 +1,485 @@ +# frozen_string_literal: true + +begin + gem "redis", ">= 4.0.1" + require "redis" + require "redis/distributed" +rescue LoadError + warn "The Redis cache store requires the redis gem, version 4.0.1 or later. Please add it to your Gemfile: `gem \"redis\", \"~> 4.0\"`" + raise +end + +# Prefer the hiredis driver but don't require it. +begin + require "redis/connection/hiredis" +rescue LoadError +end + +require "digest/sha2" +require "active_support/core_ext/marshal" + +module ActiveSupport + module Cache + module ConnectionPoolLike + def with + yield self + end + end + + ::Redis.include(ConnectionPoolLike) + ::Redis::Distributed.include(ConnectionPoolLike) + + # Redis cache store. + # + # Deployment note: Take care to use a *dedicated Redis cache* rather + # than pointing this at your existing Redis server. It won't cope well + # with mixed usage patterns and it won't expire cache entries by default. + # + # Redis cache server setup guide: https://redis.io/topics/lru-cache + # + # * Supports vanilla Redis, hiredis, and Redis::Distributed. + # * Supports Memcached-like sharding across Redises with Redis::Distributed. + # * Fault tolerant. If the Redis server is unavailable, no exceptions are + # raised. Cache fetches are all misses and writes are dropped. + # * Local cache. Hot in-memory primary cache within block/middleware scope. + # * +read_multi+ and +write_multi+ support for Redis mget/mset. Use Redis::Distributed + # 4.0.1+ for distributed mget support. + # * +delete_matched+ support for Redis KEYS globs. + class RedisCacheStore < Store + # Keys are truncated with their own SHA2 digest if they exceed 1kB + MAX_KEY_BYTESIZE = 1024 + + DEFAULT_REDIS_OPTIONS = { + connect_timeout: 20, + read_timeout: 1, + write_timeout: 1, + reconnect_attempts: 0, + } + + DEFAULT_ERROR_HANDLER = -> (method:, returning:, exception:) do + if logger + logger.error { "RedisCacheStore: #{method} failed, returned #{returning.inspect}: #{exception.class}: #{exception.message}" } + end + end + + # The maximum number of entries to receive per SCAN call. + SCAN_BATCH_SIZE = 1000 + private_constant :SCAN_BATCH_SIZE + + # Advertise cache versioning support. + def self.supports_cache_versioning? + true + end + + # Support raw values in the local cache strategy. + module LocalCacheWithRaw # :nodoc: + private + def read_entry(key, options) + entry = super + if options[:raw] && local_cache && entry + entry = deserialize_entry(entry.value) + end + entry + end + + def write_entry(key, entry, options) + if options[:raw] && local_cache + raw_entry = Entry.new(serialize_entry(entry, raw: true)) + raw_entry.expires_at = entry.expires_at + super(key, raw_entry, options) + else + super + end + end + + def write_multi_entries(entries, options) + if options[:raw] && local_cache + raw_entries = entries.map do |key, entry| + raw_entry = Entry.new(serialize_entry(entry, raw: true)) + raw_entry.expires_at = entry.expires_at + end.to_h + + super(raw_entries, options) + else + super + end + end + end + + prepend Strategy::LocalCache + prepend LocalCacheWithRaw + + class << self + # Factory method to create a new Redis instance. + # + # Handles four options: :redis block, :redis instance, single :url + # string, and multiple :url strings. + # + # Option Class Result + # :redis Proc -> options[:redis].call + # :redis Object -> options[:redis] + # :url String -> Redis.new(url: …) + # :url Array -> Redis::Distributed.new([{ url: … }, { url: … }, …]) + # + def build_redis(redis: nil, url: nil, **redis_options) #:nodoc: + urls = Array(url) + + if redis.is_a?(Proc) + redis.call + elsif redis + redis + elsif urls.size > 1 + build_redis_distributed_client urls: urls, **redis_options + else + build_redis_client url: urls.first, **redis_options + end + end + + private + def build_redis_distributed_client(urls:, **redis_options) + ::Redis::Distributed.new([], DEFAULT_REDIS_OPTIONS.merge(redis_options)).tap do |dist| + urls.each { |u| dist.add_node url: u } + end + end + + def build_redis_client(url:, **redis_options) + ::Redis.new DEFAULT_REDIS_OPTIONS.merge(redis_options.merge(url: url)) + end + end + + attr_reader :redis_options + attr_reader :max_key_bytesize + + # Creates a new Redis cache store. + # + # Handles three options: block provided to instantiate, single URL + # provided, and multiple URLs provided. + # + # :redis Proc -> options[:redis].call + # :url String -> Redis.new(url: …) + # :url Array -> Redis::Distributed.new([{ url: … }, { url: … }, …]) + # + # No namespace is set by default. Provide one if the Redis cache + # server is shared with other apps: <tt>namespace: 'myapp-cache'</tt>. + # + # Compression is enabled by default with a 1kB threshold, so cached + # values larger than 1kB are automatically compressed. Disable by + # passing <tt>compress: false</tt> or change the threshold by passing + # <tt>compress_threshold: 4.kilobytes</tt>. + # + # No expiry is set on cache entries by default. Redis is expected to + # be configured with an eviction policy that automatically deletes + # least-recently or -frequently used keys when it reaches max memory. + # See https://redis.io/topics/lru-cache for cache server setup. + # + # Race condition TTL is not set by default. This can be used to avoid + # "thundering herd" cache writes when hot cache entries are expired. + # See <tt>ActiveSupport::Cache::Store#fetch</tt> for more. + def initialize(namespace: nil, compress: true, compress_threshold: 1.kilobyte, expires_in: nil, race_condition_ttl: nil, error_handler: DEFAULT_ERROR_HANDLER, **redis_options) + @redis_options = redis_options + + @max_key_bytesize = MAX_KEY_BYTESIZE + @error_handler = error_handler + + super namespace: namespace, + compress: compress, compress_threshold: compress_threshold, + expires_in: expires_in, race_condition_ttl: race_condition_ttl + end + + def redis + @redis ||= begin + pool_options = self.class.send(:retrieve_pool_options, redis_options) + + if pool_options.any? + self.class.send(:ensure_connection_pool_added!) + ::ConnectionPool.new(pool_options) { self.class.build_redis(**redis_options) } + else + self.class.build_redis(**redis_options) + end + end + end + + def inspect + instance = @redis || @redis_options + "<##{self.class} options=#{options.inspect} redis=#{instance.inspect}>" + end + + # Cache Store API implementation. + # + # Read multiple values at once. Returns a hash of requested keys -> + # fetched values. + def read_multi(*names) + if mget_capable? + instrument(:read_multi, names, options) do |payload| + read_multi_mget(*names).tap do |results| + payload[:hits] = results.keys + end + end + else + super + end + end + + # Cache Store API implementation. + # + # Supports Redis KEYS glob patterns: + # + # h?llo matches hello, hallo and hxllo + # h*llo matches hllo and heeeello + # h[ae]llo matches hello and hallo, but not hillo + # h[^e]llo matches hallo, hbllo, ... but not hello + # h[a-b]llo matches hallo and hbllo + # + # Use \ to escape special characters if you want to match them verbatim. + # + # See https://redis.io/commands/KEYS for more. + # + # Failsafe: Raises errors. + def delete_matched(matcher, options = nil) + instrument :delete_matched, matcher do + unless String === matcher + raise ArgumentError, "Only Redis glob strings are supported: #{matcher.inspect}" + end + redis.with do |c| + pattern = namespace_key(matcher, options) + cursor = "0" + # Fetch keys in batches using SCAN to avoid blocking the Redis server. + begin + cursor, keys = c.scan(cursor, match: pattern, count: SCAN_BATCH_SIZE) + c.del(*keys) unless keys.empty? + end until cursor == "0" + end + end + end + + # Cache Store API implementation. + # + # Increment a cached value. This method uses the Redis incr atomic + # operator and can only be used on values written with the :raw option. + # Calling it on a value not stored with :raw will initialize that value + # to zero. + # + # Failsafe: Raises errors. + def increment(name, amount = 1, options = nil) + instrument :increment, name, amount: amount do + failsafe :increment do + options = merged_options(options) + key = normalize_key(name, options) + + redis.with do |c| + c.incrby(key, amount).tap do + write_key_expiry(c, key, options) + end + end + end + end + end + + # Cache Store API implementation. + # + # Decrement a cached value. This method uses the Redis decr atomic + # operator and can only be used on values written with the :raw option. + # Calling it on a value not stored with :raw will initialize that value + # to zero. + # + # Failsafe: Raises errors. + def decrement(name, amount = 1, options = nil) + instrument :decrement, name, amount: amount do + failsafe :decrement do + options = merged_options(options) + key = normalize_key(name, options) + + redis.with do |c| + c.decrby(key, amount).tap do + write_key_expiry(c, key, options) + end + end + end + end + end + + # Cache Store API implementation. + # + # Removes expired entries. Handled natively by Redis least-recently-/ + # least-frequently-used expiry, so manual cleanup is not supported. + def cleanup(options = nil) + super + end + + # Clear the entire cache on all Redis servers. Safe to use on + # shared servers if the cache is namespaced. + # + # Failsafe: Raises errors. + def clear(options = nil) + failsafe :clear do + if namespace = merged_options(options)[:namespace] + delete_matched "*", namespace: namespace + else + redis.with { |c| c.flushdb } + end + end + end + + def mget_capable? #:nodoc: + set_redis_capabilities unless defined? @mget_capable + @mget_capable + end + + def mset_capable? #:nodoc: + set_redis_capabilities unless defined? @mset_capable + @mset_capable + end + + private + def set_redis_capabilities + case redis + when Redis::Distributed + @mget_capable = true + @mset_capable = false + else + @mget_capable = true + @mset_capable = true + end + end + + # Store provider interface: + # Read an entry from the cache. + def read_entry(key, options = nil) + failsafe :read_entry do + deserialize_entry redis.with { |c| c.get(key) } + end + end + + def read_multi_entries(names, _options) + if mget_capable? + read_multi_mget(*names) + else + super + end + end + + def read_multi_mget(*names) + options = names.extract_options! + options = merged_options(options) + + keys = names.map { |name| normalize_key(name, options) } + + values = failsafe(:read_multi_mget, returning: {}) do + redis.with { |c| c.mget(*keys) } + end + + names.zip(values).each_with_object({}) do |(name, value), results| + if value + entry = deserialize_entry(value) + unless entry.nil? || entry.expired? || entry.mismatched?(normalize_version(name, options)) + results[name] = entry.value + end + end + end + end + + # Write an entry to the cache. + # + # Requires Redis 2.6.12+ for extended SET options. + def write_entry(key, entry, unless_exist: false, raw: false, expires_in: nil, race_condition_ttl: nil, **options) + serialized_entry = serialize_entry(entry, raw: raw) + + # If race condition TTL is in use, ensure that cache entries + # stick around a bit longer after they would have expired + # so we can purposefully serve stale entries. + if race_condition_ttl && expires_in && expires_in > 0 && !raw + expires_in += 5.minutes + end + + failsafe :write_entry, returning: false do + if unless_exist || expires_in + modifiers = {} + modifiers[:nx] = unless_exist + modifiers[:px] = (1000 * expires_in.to_f).ceil if expires_in + + redis.with { |c| c.set key, serialized_entry, modifiers } + else + redis.with { |c| c.set key, serialized_entry } + end + end + end + + def write_key_expiry(client, key, options) + if options[:expires_in] && client.ttl(key).negative? + client.expire key, options[:expires_in].to_i + end + end + + # Delete an entry from the cache. + def delete_entry(key, options) + failsafe :delete_entry, returning: false do + redis.with { |c| c.del key } + end + end + + # Nonstandard store provider API to write multiple values at once. + def write_multi_entries(entries, expires_in: nil, **options) + if entries.any? + if mset_capable? && expires_in.nil? + failsafe :write_multi_entries do + redis.with { |c| c.mapped_mset(serialize_entries(entries, raw: options[:raw])) } + end + else + super + end + end + end + + # Truncate keys that exceed 1kB. + def normalize_key(key, options) + truncate_key super.b + end + + def truncate_key(key) + if key.bytesize > max_key_bytesize + suffix = ":sha2:#{::Digest::SHA2.hexdigest(key)}" + truncate_at = max_key_bytesize - suffix.bytesize + "#{key.byteslice(0, truncate_at)}#{suffix}" + else + key + end + end + + def deserialize_entry(serialized_entry) + if serialized_entry + entry = Marshal.load(serialized_entry) rescue serialized_entry + entry.is_a?(Entry) ? entry : Entry.new(entry) + end + end + + def serialize_entry(entry, raw: false) + if raw + entry.value.to_s + else + Marshal.dump(entry) + end + end + + def serialize_entries(entries, raw: false) + entries.transform_values do |entry| + serialize_entry entry, raw: raw + end + end + + def failsafe(method, returning: nil) + yield + rescue ::Redis::BaseConnectionError => e + handle_exception exception: e, method: method, returning: returning + returning + end + + def handle_exception(exception:, method:, returning:) + if @error_handler + @error_handler.(method: method, exception: exception, returning: returning) + end + rescue => failsafe + warn "RedisCacheStore ignored exception in handle_exception: #{failsafe.class}: #{failsafe.message}\n #{failsafe.backtrace.join("\n ")}" + end + end + end +end diff --git a/activesupport/lib/active_support/cache/strategy/local_cache.rb b/activesupport/lib/active_support/cache/strategy/local_cache.rb new file mode 100644 index 0000000000..39b32fc7f6 --- /dev/null +++ b/activesupport/lib/active_support/cache/strategy/local_cache.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/duplicable" +require "active_support/core_ext/string/inflections" +require "active_support/per_thread_registry" + +module ActiveSupport + module Cache + module Strategy + # Caches that implement LocalCache will be backed by an in-memory cache for the + # duration of a block. Repeated calls to the cache for the same key will hit the + # in-memory cache for faster access. + module LocalCache + autoload :Middleware, "active_support/cache/strategy/local_cache_middleware" + + # Class for storing and registering the local caches. + class LocalCacheRegistry # :nodoc: + extend ActiveSupport::PerThreadRegistry + + def initialize + @registry = {} + end + + def cache_for(local_cache_key) + @registry[local_cache_key] + end + + def set_cache_for(local_cache_key, value) + @registry[local_cache_key] = value + end + + def self.set_cache_for(l, v); instance.set_cache_for l, v; end + def self.cache_for(l); instance.cache_for l; end + end + + # Simple memory backed cache. This cache is not thread safe and is intended only + # for serving as a temporary memory cache for a single thread. + class LocalStore < Store + def initialize + super + @data = {} + end + + # Don't allow synchronizing since it isn't thread safe. + def synchronize # :nodoc: + yield + end + + def clear(options = nil) + @data.clear + end + + def read_entry(key, options) + @data[key] + end + + def read_multi_entries(keys, options) + values = {} + + keys.each do |name| + entry = read_entry(name, options) + values[name] = entry.value if entry + end + + values + end + + def write_entry(key, value, options) + @data[key] = value + true + end + + def delete_entry(key, options) + !!@data.delete(key) + end + + def fetch_entry(key, options = nil) # :nodoc: + @data.fetch(key) { @data[key] = yield } + end + end + + # Use a local cache for the duration of block. + def with_local_cache + use_temporary_local_cache(LocalStore.new) { yield } + end + + # Middleware class can be inserted as a Rack handler to be local cache for the + # duration of request. + def middleware + @middleware ||= Middleware.new( + "ActiveSupport::Cache::Strategy::LocalCache", + local_cache_key) + end + + def clear(options = nil) # :nodoc: + return super unless cache = local_cache + cache.clear(options) + super + end + + def cleanup(options = nil) # :nodoc: + return super unless cache = local_cache + cache.clear + super + end + + def increment(name, amount = 1, options = nil) # :nodoc: + return super unless local_cache + value = bypass_local_cache { super } + write_cache_value(name, value, options) + value + end + + def decrement(name, amount = 1, options = nil) # :nodoc: + return super unless local_cache + value = bypass_local_cache { super } + write_cache_value(name, value, options) + value + end + + private + def read_entry(key, options) + if cache = local_cache + cache.fetch_entry(key) { super } + else + super + end + end + + def read_multi_entries(keys, options) + return super unless local_cache + + local_entries = local_cache.read_multi_entries(keys, options) + missed_keys = keys - local_entries.keys + + if missed_keys.any? + local_entries.merge!(super(missed_keys, options)) + else + local_entries + end + end + + def write_entry(key, entry, options) + if options[:unless_exist] + local_cache.delete_entry(key, options) if local_cache + else + local_cache.write_entry(key, entry, options) if local_cache + end + + super + end + + def delete_entry(key, options) + local_cache.delete_entry(key, options) if local_cache + super + end + + def write_cache_value(name, value, options) + name = normalize_key(name, options) + cache = local_cache + cache.mute do + if value + cache.write(name, value, options) + else + cache.delete(name, options) + end + end + end + + def local_cache_key + @local_cache_key ||= "#{self.class.name.underscore}_local_cache_#{object_id}".gsub(/[\/-]/, "_").to_sym + end + + def local_cache + LocalCacheRegistry.cache_for(local_cache_key) + end + + def bypass_local_cache + use_temporary_local_cache(nil) { yield } + end + + def use_temporary_local_cache(temporary_cache) + save_cache = LocalCacheRegistry.cache_for(local_cache_key) + begin + LocalCacheRegistry.set_cache_for(local_cache_key, temporary_cache) + yield + ensure + LocalCacheRegistry.set_cache_for(local_cache_key, save_cache) + end + end + end + end + end +end diff --git a/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb new file mode 100644 index 0000000000..62542bdb22 --- /dev/null +++ b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rack/body_proxy" +require "rack/utils" + +module ActiveSupport + module Cache + module Strategy + module LocalCache + #-- + # This class wraps up local storage for middlewares. Only the middleware method should + # construct them. + class Middleware # :nodoc: + attr_reader :name, :local_cache_key + + def initialize(name, local_cache_key) + @name = name + @local_cache_key = local_cache_key + @app = nil + end + + def new(app) + @app = app + self + end + + def call(env) + LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new) + response = @app.call(env) + response[2] = ::Rack::BodyProxy.new(response[2]) do + LocalCacheRegistry.set_cache_for(local_cache_key, nil) + end + cleanup_on_body_close = true + response + rescue Rack::Utils::InvalidParameterError + [400, {}, []] + ensure + LocalCacheRegistry.set_cache_for(local_cache_key, nil) unless + cleanup_on_body_close + end + end + end + end + end +end diff --git a/activesupport/lib/active_support/callbacks.rb b/activesupport/lib/active_support/callbacks.rb new file mode 100644 index 0000000000..d0644a0f7e --- /dev/null +++ b/activesupport/lib/active_support/callbacks.rb @@ -0,0 +1,856 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "active_support/descendants_tracker" +require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/class/attribute" +require "active_support/core_ext/kernel/reporting" +require "active_support/core_ext/kernel/singleton_class" +require "active_support/core_ext/string/filters" +require "active_support/deprecation" +require "thread" + +module ActiveSupport + # Callbacks are code hooks that are run at key points in an object's life cycle. + # The typical use case is to have a base class define a set of callbacks + # relevant to the other functionality it supplies, so that subclasses can + # install callbacks that enhance or modify the base functionality without + # needing to override or redefine methods of the base class. + # + # Mixing in this module allows you to define the events in the object's + # life cycle that will support callbacks (via +ClassMethods.define_callbacks+), + # set the instance methods, procs, or callback objects to be called (via + # +ClassMethods.set_callback+), and run the installed callbacks at the + # appropriate times (via +run_callbacks+). + # + # By default callbacks are halted by throwing +:abort+. + # See +ClassMethods.define_callbacks+ for details. + # + # Three kinds of callbacks are supported: before callbacks, run before a + # certain event; after callbacks, run after the event; and around callbacks, + # blocks that surround the event, triggering it when they yield. Callback code + # can be contained in instance methods, procs or lambdas, or callback objects + # that respond to certain predetermined methods. See +ClassMethods.set_callback+ + # for details. + # + # class Record + # include ActiveSupport::Callbacks + # define_callbacks :save + # + # def save + # run_callbacks :save do + # puts "- save" + # end + # end + # end + # + # class PersonRecord < Record + # set_callback :save, :before, :saving_message + # def saving_message + # puts "saving..." + # end + # + # set_callback :save, :after do |object| + # puts "saved" + # end + # end + # + # person = PersonRecord.new + # person.save + # + # Output: + # saving... + # - save + # saved + module Callbacks + extend Concern + + included do + extend ActiveSupport::DescendantsTracker + class_attribute :__callbacks, instance_writer: false, default: {} + end + + CALLBACK_FILTER_TYPES = [:before, :after, :around] + + # Runs the callbacks for the given event. + # + # Calls the before and around callbacks in the order they were set, yields + # the block (if given one), and then runs the after callbacks in reverse + # order. + # + # If the callback chain was halted, returns +false+. Otherwise returns the + # result of the block, +nil+ if no callbacks have been set, or +true+ + # if callbacks have been set but no block is given. + # + # run_callbacks :save do + # save + # end + # + #-- + # + # As this method is used in many places, and often wraps large portions of + # user code, it has an additional design goal of minimizing its impact on + # the visible call stack. An exception from inside a :before or :after + # callback can be as noisy as it likes -- but when control has passed + # smoothly through and into the supplied block, we want as little evidence + # as possible that we were here. + def run_callbacks(kind) + callbacks = __callbacks[kind.to_sym] + + if callbacks.empty? + yield if block_given? + else + env = Filters::Environment.new(self, false, nil) + next_sequence = callbacks.compile + + invoke_sequence = Proc.new do + skipped = nil + while true + current = next_sequence + current.invoke_before(env) + if current.final? + env.value = !env.halted && (!block_given? || yield) + elsif current.skip?(env) + (skipped ||= []) << current + next_sequence = next_sequence.nested + next + else + next_sequence = next_sequence.nested + begin + target, block, method, *arguments = current.expand_call_template(env, invoke_sequence) + target.send(method, *arguments, &block) + ensure + next_sequence = current + end + end + current.invoke_after(env) + skipped.pop.invoke_after(env) while skipped && skipped.first + break env.value + end + end + + # Common case: no 'around' callbacks defined + if next_sequence.final? + next_sequence.invoke_before(env) + env.value = !env.halted && (!block_given? || yield) + next_sequence.invoke_after(env) + env.value + else + invoke_sequence.call + end + end + end + + private + + # A hook invoked every time a before callback is halted. + # This can be overridden in ActiveSupport::Callbacks implementors in order + # to provide better debugging/logging. + def halted_callback_hook(filter) + end + + module Conditionals # :nodoc: + class Value + def initialize(&block) + @block = block + end + def call(target, value); @block.call(value); end + end + end + + module Filters + Environment = Struct.new(:target, :halted, :value) + + class Before + def self.build(callback_sequence, user_callback, user_conditions, chain_config, filter) + halted_lambda = chain_config[:terminator] + + if user_conditions.any? + halting_and_conditional(callback_sequence, user_callback, user_conditions, halted_lambda, filter) + else + halting(callback_sequence, user_callback, halted_lambda, filter) + end + end + + def self.halting_and_conditional(callback_sequence, user_callback, user_conditions, halted_lambda, filter) + callback_sequence.before do |env| + target = env.target + value = env.value + halted = env.halted + + if !halted && user_conditions.all? { |c| c.call(target, value) } + result_lambda = -> { user_callback.call target, value } + env.halted = halted_lambda.call(target, result_lambda) + if env.halted + target.send :halted_callback_hook, filter + end + end + + env + end + end + private_class_method :halting_and_conditional + + def self.halting(callback_sequence, user_callback, halted_lambda, filter) + callback_sequence.before do |env| + target = env.target + value = env.value + halted = env.halted + + unless halted + result_lambda = -> { user_callback.call target, value } + env.halted = halted_lambda.call(target, result_lambda) + + if env.halted + target.send :halted_callback_hook, filter + end + end + + env + end + end + private_class_method :halting + end + + class After + def self.build(callback_sequence, user_callback, user_conditions, chain_config) + if chain_config[:skip_after_callbacks_if_terminated] + if user_conditions.any? + halting_and_conditional(callback_sequence, user_callback, user_conditions) + else + halting(callback_sequence, user_callback) + end + else + if user_conditions.any? + conditional callback_sequence, user_callback, user_conditions + else + simple callback_sequence, user_callback + end + end + end + + def self.halting_and_conditional(callback_sequence, user_callback, user_conditions) + callback_sequence.after do |env| + target = env.target + value = env.value + halted = env.halted + + if !halted && user_conditions.all? { |c| c.call(target, value) } + user_callback.call target, value + end + + env + end + end + private_class_method :halting_and_conditional + + def self.halting(callback_sequence, user_callback) + callback_sequence.after do |env| + unless env.halted + user_callback.call env.target, env.value + end + + env + end + end + private_class_method :halting + + def self.conditional(callback_sequence, user_callback, user_conditions) + callback_sequence.after do |env| + target = env.target + value = env.value + + if user_conditions.all? { |c| c.call(target, value) } + user_callback.call target, value + end + + env + end + end + private_class_method :conditional + + def self.simple(callback_sequence, user_callback) + callback_sequence.after do |env| + user_callback.call env.target, env.value + + env + end + end + private_class_method :simple + end + end + + class Callback #:nodoc:# + def self.build(chain, filter, kind, options) + if filter.is_a?(String) + raise ArgumentError, <<-MSG.squish + Passing string to define a callback is not supported. See the `.set_callback` + documentation to see supported values. + MSG + end + + new chain.name, filter, kind, options, chain.config + end + + attr_accessor :kind, :name + attr_reader :chain_config + + def initialize(name, filter, kind, options, chain_config) + @chain_config = chain_config + @name = name + @kind = kind + @filter = filter + @key = compute_identifier filter + @if = check_conditionals(Array(options[:if])) + @unless = check_conditionals(Array(options[:unless])) + end + + def filter; @key; end + def raw_filter; @filter; end + + def merge_conditional_options(chain, if_option:, unless_option:) + options = { + if: @if.dup, + unless: @unless.dup + } + + options[:if].concat Array(unless_option) + options[:unless].concat Array(if_option) + + self.class.build chain, @filter, @kind, options + end + + def matches?(_kind, _filter) + @kind == _kind && filter == _filter + end + + def duplicates?(other) + case @filter + when Symbol + matches?(other.kind, other.filter) + else + false + end + end + + # Wraps code with filter + def apply(callback_sequence) + user_conditions = conditions_lambdas + user_callback = CallTemplate.build(@filter, self) + + case kind + when :before + Filters::Before.build(callback_sequence, user_callback.make_lambda, user_conditions, chain_config, @filter) + when :after + Filters::After.build(callback_sequence, user_callback.make_lambda, user_conditions, chain_config) + when :around + callback_sequence.around(user_callback, user_conditions) + end + end + + def current_scopes + Array(chain_config[:scope]).map { |s| public_send(s) } + end + + private + def check_conditionals(conditionals) + if conditionals.any? { |c| c.is_a?(String) } + raise ArgumentError, <<-MSG.squish + Passing string to be evaluated in :if and :unless conditional + options is not supported. Pass a symbol for an instance method, + or a lambda, proc or block, instead. + MSG + end + + conditionals + end + + def compute_identifier(filter) + case filter + when ::Proc + filter.object_id + else + filter + end + end + + def conditions_lambdas + @if.map { |c| CallTemplate.build(c, self).make_lambda } + + @unless.map { |c| CallTemplate.build(c, self).inverted_lambda } + end + end + + # A future invocation of user-supplied code (either as a callback, + # or a condition filter). + class CallTemplate # :nodoc: + def initialize(target, method, arguments, block) + @override_target = target + @method_name = method + @arguments = arguments + @override_block = block + end + + # Return the parts needed to make this call, with the given + # input values. + # + # Returns an array of the form: + # + # [target, block, method, *arguments] + # + # This array can be used as such: + # + # target.send(method, *arguments, &block) + # + # The actual invocation is left up to the caller to minimize + # call stack pollution. + def expand(target, value, block) + result = @arguments.map { |arg| + case arg + when :value; value + when :target; target + when :block; block || raise(ArgumentError) + end + } + + result.unshift @method_name + result.unshift @override_block || block + result.unshift @override_target || target + + # target, block, method, *arguments = result + # target.send(method, *arguments, &block) + result + end + + # Return a lambda that will make this call when given the input + # values. + def make_lambda + lambda do |target, value, &block| + target, block, method, *arguments = expand(target, value, block) + target.send(method, *arguments, &block) + end + end + + # Return a lambda that will make this call when given the input + # values, but then return the boolean inverse of that result. + def inverted_lambda + lambda do |target, value, &block| + target, block, method, *arguments = expand(target, value, block) + ! target.send(method, *arguments, &block) + end + end + + # Filters support: + # + # Symbols:: A method to call. + # Procs:: A proc to call with the object. + # Objects:: An object with a <tt>before_foo</tt> method on it to call. + # + # All of these objects are converted into a CallTemplate and handled + # the same after this point. + def self.build(filter, callback) + case filter + when Symbol + new(nil, filter, [], nil) + when Conditionals::Value + new(filter, :call, [:target, :value], nil) + when ::Proc + if filter.arity > 1 + new(nil, :instance_exec, [:target, :block], filter) + elsif filter.arity > 0 + new(nil, :instance_exec, [:target], filter) + else + new(nil, :instance_exec, [], filter) + end + else + method_to_call = callback.current_scopes.join("_") + + new(filter, method_to_call, [:target], nil) + end + end + end + + # Execute before and after filters in a sequence instead of + # chaining them with nested lambda calls, see: + # https://github.com/rails/rails/issues/18011 + class CallbackSequence # :nodoc: + def initialize(nested = nil, call_template = nil, user_conditions = nil) + @nested = nested + @call_template = call_template + @user_conditions = user_conditions + + @before = [] + @after = [] + end + + def before(&before) + @before.unshift(before) + self + end + + def after(&after) + @after.push(after) + self + end + + def around(call_template, user_conditions) + CallbackSequence.new(self, call_template, user_conditions) + end + + def skip?(arg) + arg.halted || !@user_conditions.all? { |c| c.call(arg.target, arg.value) } + end + + attr_reader :nested + + def final? + !@call_template + end + + def expand_call_template(arg, block) + @call_template.expand(arg.target, arg.value, block) + end + + def invoke_before(arg) + @before.each { |b| b.call(arg) } + end + + def invoke_after(arg) + @after.each { |a| a.call(arg) } + end + end + + class CallbackChain #:nodoc:# + include Enumerable + + attr_reader :name, :config + + def initialize(name, config) + @name = name + @config = { + scope: [:kind], + terminator: default_terminator + }.merge!(config) + @chain = [] + @callbacks = nil + @mutex = Mutex.new + end + + def each(&block); @chain.each(&block); end + def index(o); @chain.index(o); end + def empty?; @chain.empty?; end + + def insert(index, o) + @callbacks = nil + @chain.insert(index, o) + end + + def delete(o) + @callbacks = nil + @chain.delete(o) + end + + def clear + @callbacks = nil + @chain.clear + self + end + + def initialize_copy(other) + @callbacks = nil + @chain = other.chain.dup + @mutex = Mutex.new + end + + def compile + @callbacks || @mutex.synchronize do + final_sequence = CallbackSequence.new + @callbacks ||= @chain.reverse.inject(final_sequence) do |callback_sequence, callback| + callback.apply callback_sequence + end + end + end + + def append(*callbacks) + callbacks.each { |c| append_one(c) } + end + + def prepend(*callbacks) + callbacks.each { |c| prepend_one(c) } + end + + protected + attr_reader :chain + + private + + def append_one(callback) + @callbacks = nil + remove_duplicates(callback) + @chain.push(callback) + end + + def prepend_one(callback) + @callbacks = nil + remove_duplicates(callback) + @chain.unshift(callback) + end + + def remove_duplicates(callback) + @callbacks = nil + @chain.delete_if { |c| callback.duplicates?(c) } + end + + def default_terminator + Proc.new do |target, result_lambda| + terminate = true + catch(:abort) do + result_lambda.call + terminate = false + end + terminate + end + end + end + + module ClassMethods + def normalize_callback_params(filters, block) # :nodoc: + type = CALLBACK_FILTER_TYPES.include?(filters.first) ? filters.shift : :before + options = filters.extract_options! + filters.unshift(block) if block + [type, filters, options.dup] + end + + # This is used internally to append, prepend and skip callbacks to the + # CallbackChain. + def __update_callbacks(name) #:nodoc: + ([self] + ActiveSupport::DescendantsTracker.descendants(self)).reverse_each do |target| + chain = target.get_callbacks name + yield target, chain.dup + end + end + + # Install a callback for the given event. + # + # set_callback :save, :before, :before_method + # set_callback :save, :after, :after_method, if: :condition + # set_callback :save, :around, ->(r, block) { stuff; result = block.call; stuff } + # + # The second argument indicates whether the callback is to be run +:before+, + # +:after+, or +:around+ the event. If omitted, +:before+ is assumed. This + # means the first example above can also be written as: + # + # set_callback :save, :before_method + # + # The callback can be specified as a symbol naming an instance method; as a + # proc, lambda, or block; or as an object that responds to a certain method + # determined by the <tt>:scope</tt> argument to +define_callbacks+. + # + # If a proc, lambda, or block is given, its body is evaluated in the context + # of the current object. It can also optionally accept the current object as + # an argument. + # + # Before and around callbacks are called in the order that they are set; + # after callbacks are called in the reverse order. + # + # Around callbacks can access the return value from the event, if it + # wasn't halted, from the +yield+ call. + # + # ===== Options + # + # * <tt>:if</tt> - A symbol or an array of symbols, each naming an instance + # method or a proc; the callback will be called only when they all return + # a true value. + # + # If a proc is given, its body is evaluated in the context of the + # current object. It can also optionally accept the current object as + # an argument. + # * <tt>:unless</tt> - A symbol or an array of symbols, each naming an + # instance method or a proc; the callback will be called only when they + # all return a false value. + # + # If a proc is given, its body is evaluated in the context of the + # current object. It can also optionally accept the current object as + # an argument. + # * <tt>:prepend</tt> - If +true+, the callback will be prepended to the + # existing chain rather than appended. + def set_callback(name, *filter_list, &block) + type, filters, options = normalize_callback_params(filter_list, block) + + self_chain = get_callbacks name + mapped = filters.map do |filter| + Callback.build(self_chain, filter, type, options) + end + + __update_callbacks(name) do |target, chain| + options[:prepend] ? chain.prepend(*mapped) : chain.append(*mapped) + target.set_callbacks name, chain + end + end + + # Skip a previously set callback. Like +set_callback+, <tt>:if</tt> or + # <tt>:unless</tt> options may be passed in order to control when the + # callback is skipped. + # + # class Writer < Person + # skip_callback :validate, :before, :check_membership, if: -> { age > 18 } + # end + # + # An <tt>ArgumentError</tt> will be raised if the callback has not + # already been set (unless the <tt>:raise</tt> option is set to <tt>false</tt>). + def skip_callback(name, *filter_list, &block) + type, filters, options = normalize_callback_params(filter_list, block) + + options[:raise] = true unless options.key?(:raise) + + __update_callbacks(name) do |target, chain| + filters.each do |filter| + callback = chain.find { |c| c.matches?(type, filter) } + + if !callback && options[:raise] + raise ArgumentError, "#{type.to_s.capitalize} #{name} callback #{filter.inspect} has not been defined" + end + + if callback && (options.key?(:if) || options.key?(:unless)) + new_callback = callback.merge_conditional_options(chain, if_option: options[:if], unless_option: options[:unless]) + chain.insert(chain.index(callback), new_callback) + end + + chain.delete(callback) + end + target.set_callbacks name, chain + end + end + + # Remove all set callbacks for the given event. + def reset_callbacks(name) + callbacks = get_callbacks name + + ActiveSupport::DescendantsTracker.descendants(self).each do |target| + chain = target.get_callbacks(name).dup + callbacks.each { |c| chain.delete(c) } + target.set_callbacks name, chain + end + + set_callbacks(name, callbacks.dup.clear) + end + + # Define sets of events in the object life cycle that support callbacks. + # + # define_callbacks :validate + # define_callbacks :initialize, :save, :destroy + # + # ===== Options + # + # * <tt>:terminator</tt> - Determines when a before filter will halt the + # callback chain, preventing following before and around callbacks from + # being called and the event from being triggered. + # This should be a lambda to be executed. + # The current object and the result lambda of the callback will be provided + # to the terminator lambda. + # + # define_callbacks :validate, terminator: ->(target, result_lambda) { result_lambda.call == false } + # + # In this example, if any before validate callbacks returns +false+, + # any successive before and around callback is not executed. + # + # The default terminator halts the chain when a callback throws +:abort+. + # + # * <tt>:skip_after_callbacks_if_terminated</tt> - Determines if after + # callbacks should be terminated by the <tt>:terminator</tt> option. By + # default after callbacks are executed no matter if callback chain was + # terminated or not. This option has no effect if <tt>:terminator</tt> + # option is set to +nil+. + # + # * <tt>:scope</tt> - Indicates which methods should be executed when an + # object is used as a callback. + # + # class Audit + # def before(caller) + # puts 'Audit: before is called' + # end + # + # def before_save(caller) + # puts 'Audit: before_save is called' + # end + # end + # + # class Account + # include ActiveSupport::Callbacks + # + # define_callbacks :save + # set_callback :save, :before, Audit.new + # + # def save + # run_callbacks :save do + # puts 'save in main' + # end + # end + # end + # + # In the above case whenever you save an account the method + # <tt>Audit#before</tt> will be called. On the other hand + # + # define_callbacks :save, scope: [:kind, :name] + # + # would trigger <tt>Audit#before_save</tt> instead. That's constructed + # by calling <tt>#{kind}_#{name}</tt> on the given instance. In this + # case "kind" is "before" and "name" is "save". In this context +:kind+ + # and +:name+ have special meanings: +:kind+ refers to the kind of + # callback (before/after/around) and +:name+ refers to the method on + # which callbacks are being defined. + # + # A declaration like + # + # define_callbacks :save, scope: [:name] + # + # would call <tt>Audit#save</tt>. + # + # ===== Notes + # + # +names+ passed to +define_callbacks+ must not end with + # <tt>!</tt>, <tt>?</tt> or <tt>=</tt>. + # + # Calling +define_callbacks+ multiple times with the same +names+ will + # overwrite previous callbacks registered with +set_callback+. + def define_callbacks(*names) + options = names.extract_options! + + names.each do |name| + name = name.to_sym + + ([self] + ActiveSupport::DescendantsTracker.descendants(self)).each do |target| + target.set_callbacks name, CallbackChain.new(name, options) + end + + module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def _run_#{name}_callbacks(&block) + run_callbacks #{name.inspect}, &block + end + + def self._#{name}_callbacks + get_callbacks(#{name.inspect}) + end + + def self._#{name}_callbacks=(value) + set_callbacks(#{name.inspect}, value) + end + + def _#{name}_callbacks + __callbacks[#{name.inspect}] + end + RUBY + end + end + + protected + + def get_callbacks(name) # :nodoc: + __callbacks[name.to_sym] + end + + def set_callbacks(name, callbacks) # :nodoc: + self.__callbacks = __callbacks.merge(name.to_sym => callbacks) + end + end + end +end diff --git a/activesupport/lib/active_support/concern.rb b/activesupport/lib/active_support/concern.rb new file mode 100644 index 0000000000..5d356a0ab6 --- /dev/null +++ b/activesupport/lib/active_support/concern.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module ActiveSupport + # A typical module looks like this: + # + # module M + # def self.included(base) + # base.extend ClassMethods + # base.class_eval do + # scope :disabled, -> { where(disabled: true) } + # end + # end + # + # module ClassMethods + # ... + # end + # end + # + # By using <tt>ActiveSupport::Concern</tt> the above module could instead be + # written as: + # + # require 'active_support/concern' + # + # module M + # extend ActiveSupport::Concern + # + # included do + # scope :disabled, -> { where(disabled: true) } + # end + # + # class_methods do + # ... + # end + # end + # + # Moreover, it gracefully handles module dependencies. Given a +Foo+ module + # and a +Bar+ module which depends on the former, we would typically write the + # following: + # + # module Foo + # def self.included(base) + # base.class_eval do + # def self.method_injected_by_foo + # ... + # end + # end + # end + # end + # + # module Bar + # def self.included(base) + # base.method_injected_by_foo + # end + # end + # + # class Host + # include Foo # We need to include this dependency for Bar + # include Bar # Bar is the module that Host really needs + # end + # + # But why should +Host+ care about +Bar+'s dependencies, namely +Foo+? We + # could try to hide these from +Host+ directly including +Foo+ in +Bar+: + # + # module Bar + # include Foo + # def self.included(base) + # base.method_injected_by_foo + # end + # end + # + # class Host + # include Bar + # end + # + # Unfortunately this won't work, since when +Foo+ is included, its <tt>base</tt> + # is the +Bar+ module, not the +Host+ class. With <tt>ActiveSupport::Concern</tt>, + # module dependencies are properly resolved: + # + # require 'active_support/concern' + # + # module Foo + # extend ActiveSupport::Concern + # included do + # def self.method_injected_by_foo + # ... + # end + # end + # end + # + # module Bar + # extend ActiveSupport::Concern + # include Foo + # + # included do + # self.method_injected_by_foo + # end + # end + # + # class Host + # include Bar # It works, now Bar takes care of its dependencies + # end + module Concern + class MultipleIncludedBlocks < StandardError #:nodoc: + def initialize + super "Cannot define multiple 'included' blocks for a Concern" + end + end + + def self.extended(base) #:nodoc: + base.instance_variable_set(:@_dependencies, []) + end + + def append_features(base) + if base.instance_variable_defined?(:@_dependencies) + base.instance_variable_get(:@_dependencies) << self + false + else + return false if base < self + @_dependencies.each { |dep| base.include(dep) } + super + base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods) + base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block) + end + end + + def included(base = nil, &block) + if base.nil? + if instance_variable_defined?(:@_included_block) + if @_included_block.source_location != block.source_location + raise MultipleIncludedBlocks + end + else + @_included_block = block + end + else + super + end + end + + def class_methods(&class_methods_module_definition) + mod = const_defined?(:ClassMethods, false) ? + const_get(:ClassMethods) : + const_set(:ClassMethods, Module.new) + + mod.module_eval(&class_methods_module_definition) + end + end +end diff --git a/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb b/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb new file mode 100644 index 0000000000..a8455c0048 --- /dev/null +++ b/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "monitor" + +module ActiveSupport + module Concurrency + # A monitor that will permit dependency loading while blocked waiting for + # the lock. + class LoadInterlockAwareMonitor < Monitor + # Enters an exclusive section, but allows dependency loading while blocked + def mon_enter + mon_try_enter || + ActiveSupport::Dependencies.interlock.permit_concurrent_loads { super } + end + end + end +end diff --git a/activesupport/lib/active_support/concurrency/share_lock.rb b/activesupport/lib/active_support/concurrency/share_lock.rb new file mode 100644 index 0000000000..f18ccf1c88 --- /dev/null +++ b/activesupport/lib/active_support/concurrency/share_lock.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "thread" +require "monitor" + +module ActiveSupport + module Concurrency + # A share/exclusive lock, otherwise known as a read/write lock. + # + # https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock + class ShareLock + include MonitorMixin + + # We track Thread objects, instead of just using counters, because + # we need exclusive locks to be reentrant, and we need to be able + # to upgrade share locks to exclusive. + + def raw_state # :nodoc: + synchronize do + threads = @sleeping.keys | @sharing.keys | @waiting.keys + threads |= [@exclusive_thread] if @exclusive_thread + + data = {} + + threads.each do |thread| + purpose, compatible = @waiting[thread] + + data[thread] = { + thread: thread, + sharing: @sharing[thread], + exclusive: @exclusive_thread == thread, + purpose: purpose, + compatible: compatible, + waiting: !!@waiting[thread], + sleeper: @sleeping[thread], + } + end + + # NB: Yields while holding our *internal* synchronize lock, + # which is supposed to be used only for a few instructions at + # a time. This allows the caller to inspect additional state + # without things changing out from underneath, but would have + # disastrous effects upon normal operation. Fortunately, this + # method is only intended to be called when things have + # already gone wrong. + yield data + end + end + + def initialize + super() + + @cv = new_cond + + @sharing = Hash.new(0) + @waiting = {} + @sleeping = {} + @exclusive_thread = nil + @exclusive_depth = 0 + end + + # Returns false if +no_wait+ is set and the lock is not + # immediately available. Otherwise, returns true after the lock + # has been acquired. + # + # +purpose+ and +compatible+ work together; while this thread is + # waiting for the exclusive lock, it will yield its share (if any) + # to any other attempt whose +purpose+ appears in this attempt's + # +compatible+ list. This allows a "loose" upgrade, which, being + # less strict, prevents some classes of deadlocks. + # + # For many resources, loose upgrades are sufficient: if a thread + # is awaiting a lock, it is not running any other code. With + # +purpose+ matching, it is possible to yield only to other + # threads whose activity will not interfere. + def start_exclusive(purpose: nil, compatible: [], no_wait: false) + synchronize do + unless @exclusive_thread == Thread.current + if busy_for_exclusive?(purpose) + return false if no_wait + + yield_shares(purpose: purpose, compatible: compatible, block_share: true) do + wait_for(:start_exclusive) { busy_for_exclusive?(purpose) } + end + end + @exclusive_thread = Thread.current + end + @exclusive_depth += 1 + + true + end + end + + # Relinquish the exclusive lock. Must only be called by the thread + # that called start_exclusive (and currently holds the lock). + def stop_exclusive(compatible: []) + synchronize do + raise "invalid unlock" if @exclusive_thread != Thread.current + + @exclusive_depth -= 1 + if @exclusive_depth == 0 + @exclusive_thread = nil + + if eligible_waiters?(compatible) + yield_shares(compatible: compatible, block_share: true) do + wait_for(:stop_exclusive) { @exclusive_thread || eligible_waiters?(compatible) } + end + end + @cv.broadcast + end + end + end + + def start_sharing + synchronize do + if @sharing[Thread.current] > 0 || @exclusive_thread == Thread.current + # We already hold a lock; nothing to wait for + elsif @waiting[Thread.current] + # We're nested inside a +yield_shares+ call: we'll resume as + # soon as there isn't an exclusive lock in our way + wait_for(:start_sharing) { @exclusive_thread } + else + # This is an initial / outermost share call: any outstanding + # requests for an exclusive lock get to go first + wait_for(:start_sharing) { busy_for_sharing?(false) } + end + @sharing[Thread.current] += 1 + end + end + + def stop_sharing + synchronize do + if @sharing[Thread.current] > 1 + @sharing[Thread.current] -= 1 + else + @sharing.delete Thread.current + @cv.broadcast + end + end + end + + # Execute the supplied block while holding the Exclusive lock. If + # +no_wait+ is set and the lock is not immediately available, + # returns +nil+ without yielding. Otherwise, returns the result of + # the block. + # + # See +start_exclusive+ for other options. + def exclusive(purpose: nil, compatible: [], after_compatible: [], no_wait: false) + if start_exclusive(purpose: purpose, compatible: compatible, no_wait: no_wait) + begin + yield + ensure + stop_exclusive(compatible: after_compatible) + end + end + end + + # Execute the supplied block while holding the Share lock. + def sharing + start_sharing + begin + yield + ensure + stop_sharing + end + end + + # Temporarily give up all held Share locks while executing the + # supplied block, allowing any +compatible+ exclusive lock request + # to proceed. + def yield_shares(purpose: nil, compatible: [], block_share: false) + loose_shares = previous_wait = nil + synchronize do + if loose_shares = @sharing.delete(Thread.current) + if previous_wait = @waiting[Thread.current] + purpose = nil unless purpose == previous_wait[0] + compatible &= previous_wait[1] + end + compatible |= [false] unless block_share + @waiting[Thread.current] = [purpose, compatible] + end + + @cv.broadcast + end + + begin + yield + ensure + synchronize do + wait_for(:yield_shares) { @exclusive_thread && @exclusive_thread != Thread.current } + + if previous_wait + @waiting[Thread.current] = previous_wait + else + @waiting.delete Thread.current + end + @sharing[Thread.current] = loose_shares if loose_shares + end + end + end + + private + + # Must be called within synchronize + def busy_for_exclusive?(purpose) + busy_for_sharing?(purpose) || + @sharing.size > (@sharing[Thread.current] > 0 ? 1 : 0) + end + + def busy_for_sharing?(purpose) + (@exclusive_thread && @exclusive_thread != Thread.current) || + @waiting.any? { |t, (_, c)| t != Thread.current && !c.include?(purpose) } + end + + def eligible_waiters?(compatible) + @waiting.any? { |t, (p, _)| compatible.include?(p) && @waiting.all? { |t2, (_, c2)| t == t2 || c2.include?(p) } } + end + + def wait_for(method) + @sleeping[Thread.current] = method + @cv.wait_while { yield } + ensure + @sleeping.delete Thread.current + end + end + end +end diff --git a/activesupport/lib/active_support/configurable.rb b/activesupport/lib/active_support/configurable.rb new file mode 100644 index 0000000000..9acf674c40 --- /dev/null +++ b/activesupport/lib/active_support/configurable.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "active_support/ordered_options" + +module ActiveSupport + # Configurable provides a <tt>config</tt> method to store and retrieve + # configuration options as an <tt>OrderedHash</tt>. + module Configurable + extend ActiveSupport::Concern + + class Configuration < ActiveSupport::InheritableOptions + def compile_methods! + self.class.compile_methods!(keys) + end + + # Compiles reader methods so we don't have to go through method_missing. + def self.compile_methods!(keys) + keys.reject { |m| method_defined?(m) }.each do |key| + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{key}; _get(#{key.inspect}); end + RUBY + end + end + end + + module ClassMethods + def config + @_config ||= if respond_to?(:superclass) && superclass.respond_to?(:config) + superclass.config.inheritable_copy + else + # create a new "anonymous" class that will host the compiled reader methods + Class.new(Configuration).new + end + end + + def configure + yield config + end + + # Allows you to add shortcut so that you don't have to refer to attribute + # through config. Also look at the example for config to contrast. + # + # Defines both class and instance config accessors. + # + # class User + # include ActiveSupport::Configurable + # config_accessor :allowed_access + # end + # + # User.allowed_access # => nil + # User.allowed_access = false + # User.allowed_access # => false + # + # user = User.new + # user.allowed_access # => false + # user.allowed_access = true + # user.allowed_access # => true + # + # User.allowed_access # => false + # + # The attribute name must be a valid method name in Ruby. + # + # class User + # include ActiveSupport::Configurable + # config_accessor :"1_Badname" + # end + # # => NameError: invalid config attribute name + # + # To opt out of the instance writer method, pass <tt>instance_writer: false</tt>. + # To opt out of the instance reader method, pass <tt>instance_reader: false</tt>. + # + # class User + # include ActiveSupport::Configurable + # config_accessor :allowed_access, instance_reader: false, instance_writer: false + # end + # + # User.allowed_access = false + # User.allowed_access # => false + # + # User.new.allowed_access = true # => NoMethodError + # User.new.allowed_access # => NoMethodError + # + # Or pass <tt>instance_accessor: false</tt>, to opt out both instance methods. + # + # class User + # include ActiveSupport::Configurable + # config_accessor :allowed_access, instance_accessor: false + # end + # + # User.allowed_access = false + # User.allowed_access # => false + # + # User.new.allowed_access = true # => NoMethodError + # User.new.allowed_access # => NoMethodError + # + # Also you can pass a block to set up the attribute with a default value. + # + # class User + # include ActiveSupport::Configurable + # config_accessor :hair_colors do + # [:brown, :black, :blonde, :red] + # end + # end + # + # User.hair_colors # => [:brown, :black, :blonde, :red] + def config_accessor(*names, instance_reader: true, instance_writer: true, instance_accessor: true) # :doc: + names.each do |name| + raise NameError.new("invalid config attribute name") unless /\A[_A-Za-z]\w*\z/.match?(name) + + reader, reader_line = "def #{name}; config.#{name}; end", __LINE__ + writer, writer_line = "def #{name}=(value); config.#{name} = value; end", __LINE__ + + singleton_class.class_eval reader, __FILE__, reader_line + singleton_class.class_eval writer, __FILE__, writer_line + + if instance_accessor + class_eval reader, __FILE__, reader_line if instance_reader + class_eval writer, __FILE__, writer_line if instance_writer + end + send("#{name}=", yield) if block_given? + end + end + private :config_accessor + end + + # Reads and writes attributes from a configuration <tt>OrderedHash</tt>. + # + # require 'active_support/configurable' + # + # class User + # include ActiveSupport::Configurable + # end + # + # user = User.new + # + # user.config.allowed_access = true + # user.config.level = 1 + # + # user.config.allowed_access # => true + # user.config.level # => 1 + def config + @_config ||= self.class.config.inheritable_copy + end + end +end diff --git a/activesupport/lib/active_support/core_ext.rb b/activesupport/lib/active_support/core_ext.rb new file mode 100644 index 0000000000..f590605d84 --- /dev/null +++ b/activesupport/lib/active_support/core_ext.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Dir.glob(File.expand_path("core_ext/*.rb", __dir__)).each do |path| + require path +end diff --git a/activesupport/lib/active_support/core_ext/array.rb b/activesupport/lib/active_support/core_ext/array.rb new file mode 100644 index 0000000000..88b6567712 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/wrap" +require "active_support/core_ext/array/access" +require "active_support/core_ext/array/conversions" +require "active_support/core_ext/array/extract" +require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/array/grouping" +require "active_support/core_ext/array/inquiry" diff --git a/activesupport/lib/active_support/core_ext/array/access.rb b/activesupport/lib/active_support/core_ext/array/access.rb new file mode 100644 index 0000000000..b7ff7a3907 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/access.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +class Array + # Returns the tail of the array from +position+. + # + # %w( a b c d ).from(0) # => ["a", "b", "c", "d"] + # %w( a b c d ).from(2) # => ["c", "d"] + # %w( a b c d ).from(10) # => [] + # %w().from(0) # => [] + # %w( a b c d ).from(-2) # => ["c", "d"] + # %w( a b c ).from(-10) # => [] + def from(position) + self[position, length] || [] + end + + # Returns the beginning of the array up to +position+. + # + # %w( a b c d ).to(0) # => ["a"] + # %w( a b c d ).to(2) # => ["a", "b", "c"] + # %w( a b c d ).to(10) # => ["a", "b", "c", "d"] + # %w().to(0) # => [] + # %w( a b c d ).to(-2) # => ["a", "b", "c"] + # %w( a b c ).to(-10) # => [] + def to(position) + if position >= 0 + take position + 1 + else + self[0..position] + end + end + + # Returns a copy of the Array without the specified elements. + # + # people = ["David", "Rafael", "Aaron", "Todd"] + # people.without "Aaron", "Todd" + # # => ["David", "Rafael"] + # + # Note: This is an optimization of <tt>Enumerable#without</tt> that uses <tt>Array#-</tt> + # instead of <tt>Array#reject</tt> for performance reasons. + def without(*elements) + self - elements + end + + # Equal to <tt>self[1]</tt>. + # + # %w( a b c d e ).second # => "b" + def second + self[1] + end + + # Equal to <tt>self[2]</tt>. + # + # %w( a b c d e ).third # => "c" + def third + self[2] + end + + # Equal to <tt>self[3]</tt>. + # + # %w( a b c d e ).fourth # => "d" + def fourth + self[3] + end + + # Equal to <tt>self[4]</tt>. + # + # %w( a b c d e ).fifth # => "e" + def fifth + self[4] + end + + # Equal to <tt>self[41]</tt>. Also known as accessing "the reddit". + # + # (1..42).to_a.forty_two # => 42 + def forty_two + self[41] + end + + # Equal to <tt>self[-3]</tt>. + # + # %w( a b c d e ).third_to_last # => "c" + def third_to_last + self[-3] + end + + # Equal to <tt>self[-2]</tt>. + # + # %w( a b c d e ).second_to_last # => "d" + def second_to_last + self[-2] + end +end diff --git a/activesupport/lib/active_support/core_ext/array/conversions.rb b/activesupport/lib/active_support/core_ext/array/conversions.rb new file mode 100644 index 0000000000..ea688ed2ea --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/conversions.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "active_support/xml_mini" +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/object/to_param" +require "active_support/core_ext/object/to_query" + +class Array + # Converts the array to a comma-separated sentence where the last element is + # joined by the connector word. + # + # You can pass the following options to change the default behavior. If you + # pass an option key that doesn't exist in the list below, it will raise an + # <tt>ArgumentError</tt>. + # + # ==== Options + # + # * <tt>:words_connector</tt> - The sign or word used to join the elements + # in arrays with two or more elements (default: ", "). + # * <tt>:two_words_connector</tt> - The sign or word used to join the elements + # in arrays with two elements (default: " and "). + # * <tt>:last_word_connector</tt> - The sign or word used to join the last element + # in arrays with three or more elements (default: ", and "). + # * <tt>:locale</tt> - If +i18n+ is available, you can set a locale and use + # the connector options defined on the 'support.array' namespace in the + # corresponding dictionary file. + # + # ==== Examples + # + # [].to_sentence # => "" + # ['one'].to_sentence # => "one" + # ['one', 'two'].to_sentence # => "one and two" + # ['one', 'two', 'three'].to_sentence # => "one, two, and three" + # + # ['one', 'two'].to_sentence(passing: 'invalid option') + # # => ArgumentError: Unknown key: :passing. Valid keys are: :words_connector, :two_words_connector, :last_word_connector, :locale + # + # ['one', 'two'].to_sentence(two_words_connector: '-') + # # => "one-two" + # + # ['one', 'two', 'three'].to_sentence(words_connector: ' or ', last_word_connector: ' or at least ') + # # => "one or two or at least three" + # + # Using <tt>:locale</tt> option: + # + # # Given this locale dictionary: + # # + # # es: + # # support: + # # array: + # # words_connector: " o " + # # two_words_connector: " y " + # # last_word_connector: " o al menos " + # + # ['uno', 'dos'].to_sentence(locale: :es) + # # => "uno y dos" + # + # ['uno', 'dos', 'tres'].to_sentence(locale: :es) + # # => "uno o dos o al menos tres" + def to_sentence(options = {}) + options.assert_valid_keys(:words_connector, :two_words_connector, :last_word_connector, :locale) + + default_connectors = { + words_connector: ", ", + two_words_connector: " and ", + last_word_connector: ", and " + } + if defined?(I18n) + i18n_connectors = I18n.translate(:'support.array', locale: options[:locale], default: {}) + default_connectors.merge!(i18n_connectors) + end + options = default_connectors.merge!(options) + + case length + when 0 + "" + when 1 + "#{self[0]}" + when 2 + "#{self[0]}#{options[:two_words_connector]}#{self[1]}" + else + "#{self[0...-1].join(options[:words_connector])}#{options[:last_word_connector]}#{self[-1]}" + end + end + + # Extends <tt>Array#to_s</tt> to convert a collection of elements into a + # comma separated id list if <tt>:db</tt> argument is given as the format. + # + # Blog.all.to_formatted_s(:db) # => "1,2,3" + # Blog.none.to_formatted_s(:db) # => "null" + # [1,2].to_formatted_s # => "[1, 2]" + def to_formatted_s(format = :default) + case format + when :db + if empty? + "null" + else + collect(&:id).join(",") + end + else + to_default_s + end + end + alias_method :to_default_s, :to_s + alias_method :to_s, :to_formatted_s + + # Returns a string that represents the array in XML by invoking +to_xml+ + # on each element. Active Record collections delegate their representation + # in XML to this method. + # + # All elements are expected to respond to +to_xml+, if any of them does + # not then an exception is raised. + # + # The root node reflects the class name of the first element in plural + # if all elements belong to the same type and that's not Hash: + # + # customer.projects.to_xml + # + # <?xml version="1.0" encoding="UTF-8"?> + # <projects type="array"> + # <project> + # <amount type="decimal">20000.0</amount> + # <customer-id type="integer">1567</customer-id> + # <deal-date type="date">2008-04-09</deal-date> + # ... + # </project> + # <project> + # <amount type="decimal">57230.0</amount> + # <customer-id type="integer">1567</customer-id> + # <deal-date type="date">2008-04-15</deal-date> + # ... + # </project> + # </projects> + # + # Otherwise the root element is "objects": + # + # [{ foo: 1, bar: 2}, { baz: 3}].to_xml + # + # <?xml version="1.0" encoding="UTF-8"?> + # <objects type="array"> + # <object> + # <bar type="integer">2</bar> + # <foo type="integer">1</foo> + # </object> + # <object> + # <baz type="integer">3</baz> + # </object> + # </objects> + # + # If the collection is empty the root element is "nil-classes" by default: + # + # [].to_xml + # + # <?xml version="1.0" encoding="UTF-8"?> + # <nil-classes type="array"/> + # + # To ensure a meaningful root element use the <tt>:root</tt> option: + # + # customer_with_no_projects.projects.to_xml(root: 'projects') + # + # <?xml version="1.0" encoding="UTF-8"?> + # <projects type="array"/> + # + # By default name of the node for the children of root is <tt>root.singularize</tt>. + # You can change it with the <tt>:children</tt> option. + # + # The +options+ hash is passed downwards: + # + # Message.all.to_xml(skip_types: true) + # + # <?xml version="1.0" encoding="UTF-8"?> + # <messages> + # <message> + # <created-at>2008-03-07T09:58:18+01:00</created-at> + # <id>1</id> + # <name>1</name> + # <updated-at>2008-03-07T09:58:18+01:00</updated-at> + # <user-id>1</user-id> + # </message> + # </messages> + # + def to_xml(options = {}) + require "active_support/builder" unless defined?(Builder) + + options = options.dup + options[:indent] ||= 2 + options[:builder] ||= Builder::XmlMarkup.new(indent: options[:indent]) + options[:root] ||= \ + if first.class != Hash && all? { |e| e.is_a?(first.class) } + underscored = ActiveSupport::Inflector.underscore(first.class.name) + ActiveSupport::Inflector.pluralize(underscored).tr("/", "_") + else + "objects" + end + + builder = options[:builder] + builder.instruct! unless options.delete(:skip_instruct) + + root = ActiveSupport::XmlMini.rename_key(options[:root].to_s, options) + children = options.delete(:children) || root.singularize + attributes = options[:skip_types] ? {} : { type: "array" } + + if empty? + builder.tag!(root, attributes) + else + builder.tag!(root, attributes) do + each { |value| ActiveSupport::XmlMini.to_tag(children, value, options) } + yield builder if block_given? + end + end + end +end diff --git a/activesupport/lib/active_support/core_ext/array/extract.rb b/activesupport/lib/active_support/core_ext/array/extract.rb new file mode 100644 index 0000000000..cc5a8a3f88 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/extract.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Array + # Removes and returns the elements for which the block returns a true value. + # If no block is given, an Enumerator is returned instead. + # + # numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + # odd_numbers = numbers.extract! { |number| number.odd? } # => [1, 3, 5, 7, 9] + # numbers # => [0, 2, 4, 6, 8] + def extract! + return to_enum(:extract!) { size } unless block_given? + + extracted_elements = [] + + reject! do |element| + extracted_elements << element if yield(element) + end + + extracted_elements + end +end diff --git a/activesupport/lib/active_support/core_ext/array/extract_options.rb b/activesupport/lib/active_support/core_ext/array/extract_options.rb new file mode 100644 index 0000000000..8c7cb2e780 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/extract_options.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Hash + # By default, only instances of Hash itself are extractable. + # Subclasses of Hash may implement this method and return + # true to declare themselves as extractable. If a Hash + # is extractable, Array#extract_options! pops it from + # the Array when it is the last element of the Array. + def extractable_options? + instance_of?(Hash) + end +end + +class Array + # Extracts options from a set of arguments. Removes and returns the last + # element in the array if it's a hash, otherwise returns a blank hash. + # + # def options(*args) + # args.extract_options! + # end + # + # options(1, 2) # => {} + # options(1, 2, a: :b) # => {:a=>:b} + def extract_options! + if last.is_a?(Hash) && last.extractable_options? + pop + else + {} + end + end +end diff --git a/activesupport/lib/active_support/core_ext/array/grouping.rb b/activesupport/lib/active_support/core_ext/array/grouping.rb new file mode 100644 index 0000000000..67e760bc4b --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/grouping.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class Array + # Splits or iterates over the array in groups of size +number+, + # padding any remaining slots with +fill_with+ unless it is +false+. + # + # %w(1 2 3 4 5 6 7 8 9 10).in_groups_of(3) {|group| p group} + # ["1", "2", "3"] + # ["4", "5", "6"] + # ["7", "8", "9"] + # ["10", nil, nil] + # + # %w(1 2 3 4 5).in_groups_of(2, ' ') {|group| p group} + # ["1", "2"] + # ["3", "4"] + # ["5", " "] + # + # %w(1 2 3 4 5).in_groups_of(2, false) {|group| p group} + # ["1", "2"] + # ["3", "4"] + # ["5"] + def in_groups_of(number, fill_with = nil) + if number.to_i <= 0 + raise ArgumentError, + "Group size must be a positive integer, was #{number.inspect}" + end + + if fill_with == false + collection = self + else + # size % number gives how many extra we have; + # subtracting from number gives how many to add; + # modulo number ensures we don't add group of just fill. + padding = (number - size % number) % number + collection = dup.concat(Array.new(padding, fill_with)) + end + + if block_given? + collection.each_slice(number) { |slice| yield(slice) } + else + collection.each_slice(number).to_a + end + end + + # Splits or iterates over the array in +number+ of groups, padding any + # remaining slots with +fill_with+ unless it is +false+. + # + # %w(1 2 3 4 5 6 7 8 9 10).in_groups(3) {|group| p group} + # ["1", "2", "3", "4"] + # ["5", "6", "7", nil] + # ["8", "9", "10", nil] + # + # %w(1 2 3 4 5 6 7 8 9 10).in_groups(3, ' ') {|group| p group} + # ["1", "2", "3", "4"] + # ["5", "6", "7", " "] + # ["8", "9", "10", " "] + # + # %w(1 2 3 4 5 6 7).in_groups(3, false) {|group| p group} + # ["1", "2", "3"] + # ["4", "5"] + # ["6", "7"] + def in_groups(number, fill_with = nil) + # size.div number gives minor group size; + # size % number gives how many objects need extra accommodation; + # each group hold either division or division + 1 items. + division = size.div number + modulo = size % number + + # create a new array avoiding dup + groups = [] + start = 0 + + number.times do |index| + length = division + (modulo > 0 && modulo > index ? 1 : 0) + groups << last_group = slice(start, length) + last_group << fill_with if fill_with != false && + modulo > 0 && length == division + start += length + end + + if block_given? + groups.each { |g| yield(g) } + else + groups + end + end + + # Divides the array into one or more subarrays based on a delimiting +value+ + # or the result of an optional block. + # + # [1, 2, 3, 4, 5].split(3) # => [[1, 2], [4, 5]] + # (1..10).to_a.split { |i| i % 3 == 0 } # => [[1, 2], [4, 5], [7, 8], [10]] + def split(value = nil) + arr = dup + result = [] + if block_given? + while (idx = arr.index { |i| yield i }) + result << arr.shift(idx) + arr.shift + end + else + while (idx = arr.index(value)) + result << arr.shift(idx) + arr.shift + end + end + result << arr + end +end diff --git a/activesupport/lib/active_support/core_ext/array/inquiry.rb b/activesupport/lib/active_support/core_ext/array/inquiry.rb new file mode 100644 index 0000000000..92c61bf201 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/inquiry.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "active_support/array_inquirer" + +class Array + # Wraps the array in an +ArrayInquirer+ object, which gives a friendlier way + # to check its string-like contents. + # + # pets = [:cat, :dog].inquiry + # + # pets.cat? # => true + # pets.ferret? # => false + # + # pets.any?(:cat, :ferret) # => true + # pets.any?(:ferret, :alligator) # => false + def inquiry + ActiveSupport::ArrayInquirer.new(self) + end +end diff --git a/activesupport/lib/active_support/core_ext/array/prepend_and_append.rb b/activesupport/lib/active_support/core_ext/array/prepend_and_append.rb new file mode 100644 index 0000000000..ba3739f640 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/prepend_and_append.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_support/deprecation" + +ActiveSupport::Deprecation.warn "Ruby 2.5+ (required by Rails 6) provides Array#append and Array#prepend natively, so requiring active_support/core_ext/array/prepend_and_append is no longer necessary. Requiring it will raise LoadError in Rails 6.1." diff --git a/activesupport/lib/active_support/core_ext/array/wrap.rb b/activesupport/lib/active_support/core_ext/array/wrap.rb new file mode 100644 index 0000000000..d62f97edbf --- /dev/null +++ b/activesupport/lib/active_support/core_ext/array/wrap.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Array + # Wraps its argument in an array unless it is already an array (or array-like). + # + # Specifically: + # + # * If the argument is +nil+ an empty array is returned. + # * Otherwise, if the argument responds to +to_ary+ it is invoked, and its result returned. + # * Otherwise, returns an array with the argument as its single element. + # + # Array.wrap(nil) # => [] + # Array.wrap([1, 2, 3]) # => [1, 2, 3] + # Array.wrap(0) # => [0] + # + # This method is similar in purpose to <tt>Kernel#Array</tt>, but there are some differences: + # + # * If the argument responds to +to_ary+ the method is invoked. <tt>Kernel#Array</tt> + # moves on to try +to_a+ if the returned value is +nil+, but <tt>Array.wrap</tt> returns + # an array with the argument as its single element right away. + # * If the returned value from +to_ary+ is neither +nil+ nor an +Array+ object, <tt>Kernel#Array</tt> + # raises an exception, while <tt>Array.wrap</tt> does not, it just returns the value. + # * It does not call +to_a+ on the argument, if the argument does not respond to +to_ary+ + # it returns an array with the argument as its single element. + # + # The last point is easily explained with some enumerables: + # + # Array(foo: :bar) # => [[:foo, :bar]] + # Array.wrap(foo: :bar) # => [{:foo=>:bar}] + # + # There's also a related idiom that uses the splat operator: + # + # [*object] + # + # which returns <tt>[]</tt> for +nil+, but calls to <tt>Array(object)</tt> otherwise. + # + # The differences with <tt>Kernel#Array</tt> explained above + # apply to the rest of <tt>object</tt>s. + def self.wrap(object) + if object.nil? + [] + elsif object.respond_to?(:to_ary) + object.to_ary || [object] + else + [object] + end + end +end diff --git a/activesupport/lib/active_support/core_ext/benchmark.rb b/activesupport/lib/active_support/core_ext/benchmark.rb new file mode 100644 index 0000000000..641b58c8b8 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/benchmark.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "benchmark" + +class << Benchmark + # Benchmark realtime in milliseconds. + # + # Benchmark.realtime { User.all } + # # => 8.0e-05 + # + # Benchmark.ms { User.all } + # # => 0.074 + def ms + 1000 * realtime { yield } + end +end diff --git a/activesupport/lib/active_support/core_ext/big_decimal.rb b/activesupport/lib/active_support/core_ext/big_decimal.rb new file mode 100644 index 0000000000..9e6a9d6331 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/big_decimal.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "active_support/core_ext/big_decimal/conversions" diff --git a/activesupport/lib/active_support/core_ext/big_decimal/conversions.rb b/activesupport/lib/active_support/core_ext/big_decimal/conversions.rb new file mode 100644 index 0000000000..52bd229416 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/big_decimal/conversions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "bigdecimal" +require "bigdecimal/util" + +module ActiveSupport + module BigDecimalWithDefaultFormat #:nodoc: + def to_s(format = "F") + super(format) + end + end +end + +BigDecimal.prepend(ActiveSupport::BigDecimalWithDefaultFormat) diff --git a/activesupport/lib/active_support/core_ext/class.rb b/activesupport/lib/active_support/core_ext/class.rb new file mode 100644 index 0000000000..1c110fd07b --- /dev/null +++ b/activesupport/lib/active_support/core_ext/class.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require "active_support/core_ext/class/attribute" +require "active_support/core_ext/class/subclasses" diff --git a/activesupport/lib/active_support/core_ext/class/attribute.rb b/activesupport/lib/active_support/core_ext/class/attribute.rb new file mode 100644 index 0000000000..fa33ff945f --- /dev/null +++ b/activesupport/lib/active_support/core_ext/class/attribute.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "active_support/core_ext/kernel/singleton_class" +require "active_support/core_ext/module/redefine_method" +require "active_support/core_ext/array/extract_options" + +class Class + # Declare a class-level attribute whose value is inheritable by subclasses. + # Subclasses can change their own value and it will not impact parent class. + # + # ==== Options + # + # * <tt>:instance_reader</tt> - Sets the instance reader method (defaults to true). + # * <tt>:instance_writer</tt> - Sets the instance writer method (defaults to true). + # * <tt>:instance_accessor</tt> - Sets both instance methods (defaults to true). + # * <tt>:instance_predicate</tt> - Sets a predicate method (defaults to true). + # * <tt>:default</tt> - Sets a default value for the attribute (defaults to nil). + # + # ==== Examples + # + # class Base + # class_attribute :setting + # end + # + # class Subclass < Base + # end + # + # Base.setting = true + # Subclass.setting # => true + # Subclass.setting = false + # Subclass.setting # => false + # Base.setting # => true + # + # In the above case as long as Subclass does not assign a value to setting + # by performing <tt>Subclass.setting = _something_</tt>, <tt>Subclass.setting</tt> + # would read value assigned to parent class. Once Subclass assigns a value then + # the value assigned by Subclass would be returned. + # + # This matches normal Ruby method inheritance: think of writing an attribute + # on a subclass as overriding the reader method. However, you need to be aware + # when using +class_attribute+ with mutable structures as +Array+ or +Hash+. + # In such cases, you don't want to do changes in place. Instead use setters: + # + # Base.setting = [] + # Base.setting # => [] + # Subclass.setting # => [] + # + # # Appending in child changes both parent and child because it is the same object: + # Subclass.setting << :foo + # Base.setting # => [:foo] + # Subclass.setting # => [:foo] + # + # # Use setters to not propagate changes: + # Base.setting = [] + # Subclass.setting += [:foo] + # Base.setting # => [] + # Subclass.setting # => [:foo] + # + # For convenience, an instance predicate method is defined as well. + # To skip it, pass <tt>instance_predicate: false</tt>. + # + # Subclass.setting? # => false + # + # Instances may overwrite the class value in the same way: + # + # Base.setting = true + # object = Base.new + # object.setting # => true + # object.setting = false + # object.setting # => false + # Base.setting # => true + # + # To opt out of the instance reader method, pass <tt>instance_reader: false</tt>. + # + # object.setting # => NoMethodError + # object.setting? # => NoMethodError + # + # To opt out of the instance writer method, pass <tt>instance_writer: false</tt>. + # + # object.setting = false # => NoMethodError + # + # To opt out of both instance methods, pass <tt>instance_accessor: false</tt>. + # + # To set a default value for the attribute, pass <tt>default:</tt>, like so: + # + # class_attribute :settings, default: {} + def class_attribute(*attrs) + options = attrs.extract_options! + instance_reader = options.fetch(:instance_accessor, true) && options.fetch(:instance_reader, true) + instance_writer = options.fetch(:instance_accessor, true) && options.fetch(:instance_writer, true) + instance_predicate = options.fetch(:instance_predicate, true) + default_value = options.fetch(:default, nil) + + attrs.each do |name| + singleton_class.silence_redefinition_of_method(name) + define_singleton_method(name) { nil } + + singleton_class.silence_redefinition_of_method("#{name}?") + define_singleton_method("#{name}?") { !!public_send(name) } if instance_predicate + + ivar = "@#{name}".to_sym + + singleton_class.silence_redefinition_of_method("#{name}=") + define_singleton_method("#{name}=") do |val| + singleton_class.class_eval do + redefine_method(name) { val } + end + + if singleton_class? + class_eval do + redefine_method(name) do + if instance_variable_defined? ivar + instance_variable_get ivar + else + singleton_class.send name + end + end + end + end + val + end + + if instance_reader + redefine_method(name) do + if instance_variable_defined?(ivar) + instance_variable_get ivar + else + self.class.public_send name + end + end + + redefine_method("#{name}?") { !!public_send(name) } if instance_predicate + end + + if instance_writer + redefine_method("#{name}=") do |val| + instance_variable_set ivar, val + end + end + + unless default_value.nil? + self.send("#{name}=", default_value) + end + end + end +end diff --git a/activesupport/lib/active_support/core_ext/class/attribute_accessors.rb b/activesupport/lib/active_support/core_ext/class/attribute_accessors.rb new file mode 100644 index 0000000000..a77354e153 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/class/attribute_accessors.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# cattr_* became mattr_* aliases in 7dfbd91b0780fbd6a1dd9bfbc176e10894871d2d, +# but we keep this around for libraries that directly require it knowing they +# want cattr_*. No need to deprecate. +require "active_support/core_ext/module/attribute_accessors" diff --git a/activesupport/lib/active_support/core_ext/class/subclasses.rb b/activesupport/lib/active_support/core_ext/class/subclasses.rb new file mode 100644 index 0000000000..56fb46a88d --- /dev/null +++ b/activesupport/lib/active_support/core_ext/class/subclasses.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Class + begin + # Test if this Ruby supports each_object against singleton_class + ObjectSpace.each_object(Numeric.singleton_class) { } + + # Returns an array with all classes that are < than its receiver. + # + # class C; end + # C.descendants # => [] + # + # class B < C; end + # C.descendants # => [B] + # + # class A < B; end + # C.descendants # => [B, A] + # + # class D < C; end + # C.descendants # => [B, A, D] + def descendants + descendants = [] + ObjectSpace.each_object(singleton_class) do |k| + next if k.singleton_class? + descendants.unshift k unless k == self + end + descendants + end + rescue StandardError # JRuby 9.0.4.0 and earlier + def descendants + descendants = [] + ObjectSpace.each_object(Class) do |k| + descendants.unshift k if k < self + end + descendants.uniq! + descendants + end + end + + # Returns an array with the direct children of +self+. + # + # class Foo; end + # class Bar < Foo; end + # class Baz < Bar; end + # + # Foo.subclasses # => [Bar] + def subclasses + subclasses, chain = [], descendants + chain.each do |k| + subclasses << k unless chain.any? { |c| c > k } + end + subclasses + end +end diff --git a/activesupport/lib/active_support/core_ext/date.rb b/activesupport/lib/active_support/core_ext/date.rb new file mode 100644 index 0000000000..cce73f2db2 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "active_support/core_ext/date/acts_like" +require "active_support/core_ext/date/blank" +require "active_support/core_ext/date/calculations" +require "active_support/core_ext/date/conversions" +require "active_support/core_ext/date/zones" diff --git a/activesupport/lib/active_support/core_ext/date/acts_like.rb b/activesupport/lib/active_support/core_ext/date/acts_like.rb new file mode 100644 index 0000000000..c8077f3774 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date/acts_like.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/acts_like" + +class Date + # Duck-types as a Date-like class. See Object#acts_like?. + def acts_like_date? + true + end +end diff --git a/activesupport/lib/active_support/core_ext/date/blank.rb b/activesupport/lib/active_support/core_ext/date/blank.rb new file mode 100644 index 0000000000..e6271c79b3 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date/blank.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "date" + +class Date #:nodoc: + # No Date is blank: + # + # Date.today.blank? # => false + # + # @return [false] + def blank? + false + end +end diff --git a/activesupport/lib/active_support/core_ext/date/calculations.rb b/activesupport/lib/active_support/core_ext/date/calculations.rb new file mode 100644 index 0000000000..1cd7acb05d --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date/calculations.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "date" +require "active_support/duration" +require "active_support/core_ext/object/acts_like" +require "active_support/core_ext/date/zones" +require "active_support/core_ext/time/zones" +require "active_support/core_ext/date_and_time/calculations" + +class Date + include DateAndTime::Calculations + + class << self + attr_accessor :beginning_of_week_default + + # Returns the week start (e.g. :monday) for the current request, if this has been set (via Date.beginning_of_week=). + # If <tt>Date.beginning_of_week</tt> has not been set for the current request, returns the week start specified in <tt>config.beginning_of_week</tt>. + # If no config.beginning_of_week was specified, returns :monday. + def beginning_of_week + Thread.current[:beginning_of_week] || beginning_of_week_default || :monday + end + + # Sets <tt>Date.beginning_of_week</tt> to a week start (e.g. :monday) for current request/thread. + # + # This method accepts any of the following day symbols: + # :monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday + def beginning_of_week=(week_start) + Thread.current[:beginning_of_week] = find_beginning_of_week!(week_start) + end + + # Returns week start day symbol (e.g. :monday), or raises an +ArgumentError+ for invalid day symbol. + def find_beginning_of_week!(week_start) + raise ArgumentError, "Invalid beginning of week: #{week_start}" unless ::Date::DAYS_INTO_WEEK.key?(week_start) + week_start + end + + # Returns a new Date representing the date 1 day ago (i.e. yesterday's date). + def yesterday + ::Date.current.yesterday + end + + # Returns a new Date representing the date 1 day after today (i.e. tomorrow's date). + def tomorrow + ::Date.current.tomorrow + end + + # Returns Time.zone.today when <tt>Time.zone</tt> or <tt>config.time_zone</tt> are set, otherwise just returns Date.today. + def current + ::Time.zone ? ::Time.zone.today : ::Date.today + end + end + + # Converts Date to a Time (or DateTime if necessary) with the time portion set to the beginning of the day (0:00) + # and then subtracts the specified number of seconds. + def ago(seconds) + in_time_zone.since(-seconds) + end + + # Converts Date to a Time (or DateTime if necessary) with the time portion set to the beginning of the day (0:00) + # and then adds the specified number of seconds + def since(seconds) + in_time_zone.since(seconds) + end + alias :in :since + + # Converts Date to a Time (or DateTime if necessary) with the time portion set to the beginning of the day (0:00) + def beginning_of_day + in_time_zone + end + alias :midnight :beginning_of_day + alias :at_midnight :beginning_of_day + alias :at_beginning_of_day :beginning_of_day + + # Converts Date to a Time (or DateTime if necessary) with the time portion set to the middle of the day (12:00) + def middle_of_day + in_time_zone.middle_of_day + end + alias :midday :middle_of_day + alias :noon :middle_of_day + alias :at_midday :middle_of_day + alias :at_noon :middle_of_day + alias :at_middle_of_day :middle_of_day + + # Converts Date to a Time (or DateTime if necessary) with the time portion set to the end of the day (23:59:59) + def end_of_day + in_time_zone.end_of_day + end + alias :at_end_of_day :end_of_day + + def plus_with_duration(other) #:nodoc: + if ActiveSupport::Duration === other + other.since(self) + else + plus_without_duration(other) + end + end + alias_method :plus_without_duration, :+ + alias_method :+, :plus_with_duration + + def minus_with_duration(other) #:nodoc: + if ActiveSupport::Duration === other + plus_with_duration(-other) + else + minus_without_duration(other) + end + end + alias_method :minus_without_duration, :- + alias_method :-, :minus_with_duration + + # Provides precise Date calculations for years, months, and days. The +options+ parameter takes a hash with + # any of these keys: <tt>:years</tt>, <tt>:months</tt>, <tt>:weeks</tt>, <tt>:days</tt>. + def advance(options) + options = options.dup + d = self + d = d >> options.delete(:years) * 12 if options[:years] + d = d >> options.delete(:months) if options[:months] + d = d + options.delete(:weeks) * 7 if options[:weeks] + d = d + options.delete(:days) if options[:days] + d + end + + # Returns a new Date where one or more of the elements have been changed according to the +options+ parameter. + # The +options+ parameter is a hash with a combination of these keys: <tt>:year</tt>, <tt>:month</tt>, <tt>:day</tt>. + # + # Date.new(2007, 5, 12).change(day: 1) # => Date.new(2007, 5, 1) + # Date.new(2007, 5, 12).change(year: 2005, month: 1) # => Date.new(2005, 1, 12) + def change(options) + ::Date.new( + options.fetch(:year, year), + options.fetch(:month, month), + options.fetch(:day, day) + ) + end + + # Allow Date to be compared with Time by converting to DateTime and relying on the <=> from there. + def compare_with_coercion(other) + if other.is_a?(Time) + to_datetime <=> other + else + compare_without_coercion(other) + end + end + alias_method :compare_without_coercion, :<=> + alias_method :<=>, :compare_with_coercion +end diff --git a/activesupport/lib/active_support/core_ext/date/conversions.rb b/activesupport/lib/active_support/core_ext/date/conversions.rb new file mode 100644 index 0000000000..870119dc7f --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date/conversions.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "date" +require "active_support/inflector/methods" +require "active_support/core_ext/date/zones" +require "active_support/core_ext/module/redefine_method" + +class Date + DATE_FORMATS = { + short: "%d %b", + long: "%B %d, %Y", + db: "%Y-%m-%d", + number: "%Y%m%d", + long_ordinal: lambda { |date| + day_format = ActiveSupport::Inflector.ordinalize(date.day) + date.strftime("%B #{day_format}, %Y") # => "April 25th, 2007" + }, + rfc822: "%d %b %Y", + iso8601: lambda { |date| date.iso8601 } + } + + # Convert to a formatted string. See DATE_FORMATS for predefined formats. + # + # This method is aliased to <tt>to_s</tt>. + # + # date = Date.new(2007, 11, 10) # => Sat, 10 Nov 2007 + # + # date.to_formatted_s(:db) # => "2007-11-10" + # date.to_s(:db) # => "2007-11-10" + # + # date.to_formatted_s(:short) # => "10 Nov" + # date.to_formatted_s(:number) # => "20071110" + # date.to_formatted_s(:long) # => "November 10, 2007" + # date.to_formatted_s(:long_ordinal) # => "November 10th, 2007" + # date.to_formatted_s(:rfc822) # => "10 Nov 2007" + # date.to_formatted_s(:iso8601) # => "2007-11-10" + # + # == Adding your own date formats to to_formatted_s + # You can add your own formats to the Date::DATE_FORMATS hash. + # Use the format name as the hash key and either a strftime string + # or Proc instance that takes a date argument as the value. + # + # # config/initializers/date_formats.rb + # Date::DATE_FORMATS[:month_and_year] = '%B %Y' + # Date::DATE_FORMATS[:short_ordinal] = ->(date) { date.strftime("%B #{date.day.ordinalize}") } + def to_formatted_s(format = :default) + if formatter = DATE_FORMATS[format] + if formatter.respond_to?(:call) + formatter.call(self).to_s + else + strftime(formatter) + end + else + to_default_s + end + end + alias_method :to_default_s, :to_s + alias_method :to_s, :to_formatted_s + + # Overrides the default inspect method with a human readable one, e.g., "Mon, 21 Feb 2005" + def readable_inspect + strftime("%a, %d %b %Y") + end + alias_method :default_inspect, :inspect + alias_method :inspect, :readable_inspect + + silence_redefinition_of_method :to_time + + # Converts a Date instance to a Time, where the time is set to the beginning of the day. + # The timezone can be either :local or :utc (default :local). + # + # date = Date.new(2007, 11, 10) # => Sat, 10 Nov 2007 + # + # date.to_time # => 2007-11-10 00:00:00 0800 + # date.to_time(:local) # => 2007-11-10 00:00:00 0800 + # + # date.to_time(:utc) # => 2007-11-10 00:00:00 UTC + # + # NOTE: The :local timezone is Ruby's *process* timezone, i.e. ENV['TZ']. + # If the *application's* timezone is needed, then use +in_time_zone+ instead. + def to_time(form = :local) + raise ArgumentError, "Expected :local or :utc, got #{form.inspect}." unless [:local, :utc].include?(form) + ::Time.send(form, year, month, day) + end + + silence_redefinition_of_method :xmlschema + + # Returns a string which represents the time in used time zone as DateTime + # defined by XML Schema: + # + # date = Date.new(2015, 05, 23) # => Sat, 23 May 2015 + # date.xmlschema # => "2015-05-23T00:00:00+04:00" + def xmlschema + in_time_zone.xmlschema + end +end diff --git a/activesupport/lib/active_support/core_ext/date/zones.rb b/activesupport/lib/active_support/core_ext/date/zones.rb new file mode 100644 index 0000000000..2dcf97cff8 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date/zones.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "date" +require "active_support/core_ext/date_and_time/zones" + +class Date + include DateAndTime::Zones +end diff --git a/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb b/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb new file mode 100644 index 0000000000..05abd83221 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb @@ -0,0 +1,381 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/try" + +module DateAndTime + module Calculations + DAYS_INTO_WEEK = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6 + } + WEEKEND_DAYS = [ 6, 0 ] + + # Returns a new date/time representing yesterday. + def yesterday + advance(days: -1) + end + + # Returns a new date/time the specified number of days ago. + def prev_day(days = 1) + advance(days: -days) + end + + # Returns a new date/time representing tomorrow. + def tomorrow + advance(days: 1) + end + + # Returns a new date/time the specified number of days in the future. + def next_day(days = 1) + advance(days: days) + end + + # Returns true if the date/time is today. + def today? + to_date == ::Date.current + end + + # Returns true if the date/time is in the past. + def past? + self < self.class.current + end + + # Returns true if the date/time is in the future. + def future? + self > self.class.current + end + + # Returns true if the date/time falls on a Saturday or Sunday. + def on_weekend? + WEEKEND_DAYS.include?(wday) + end + + # Returns true if the date/time does not fall on a Saturday or Sunday. + def on_weekday? + !WEEKEND_DAYS.include?(wday) + end + + # Returns true if the date/time falls before <tt>date_or_time</tt>. + def before?(date_or_time) + self < date_or_time + end + + # Returns true if the date/time falls after <tt>date_or_time</tt>. + def after?(date_or_time) + self > date_or_time + end + + # Returns a new date/time the specified number of days ago. + def days_ago(days) + advance(days: -days) + end + + # Returns a new date/time the specified number of days in the future. + def days_since(days) + advance(days: days) + end + + # Returns a new date/time the specified number of weeks ago. + def weeks_ago(weeks) + advance(weeks: -weeks) + end + + # Returns a new date/time the specified number of weeks in the future. + def weeks_since(weeks) + advance(weeks: weeks) + end + + # Returns a new date/time the specified number of months ago. + def months_ago(months) + advance(months: -months) + end + + # Returns a new date/time the specified number of months in the future. + def months_since(months) + advance(months: months) + end + + # Returns a new date/time the specified number of years ago. + def years_ago(years) + advance(years: -years) + end + + # Returns a new date/time the specified number of years in the future. + def years_since(years) + advance(years: years) + end + + # Returns a new date/time at the start of the month. + # + # today = Date.today # => Thu, 18 Jun 2015 + # today.beginning_of_month # => Mon, 01 Jun 2015 + # + # +DateTime+ objects will have a time set to 0:00. + # + # now = DateTime.current # => Thu, 18 Jun 2015 15:23:13 +0000 + # now.beginning_of_month # => Mon, 01 Jun 2015 00:00:00 +0000 + def beginning_of_month + first_hour(change(day: 1)) + end + alias :at_beginning_of_month :beginning_of_month + + # Returns a new date/time at the start of the quarter. + # + # today = Date.today # => Fri, 10 Jul 2015 + # today.beginning_of_quarter # => Wed, 01 Jul 2015 + # + # +DateTime+ objects will have a time set to 0:00. + # + # now = DateTime.current # => Fri, 10 Jul 2015 18:41:29 +0000 + # now.beginning_of_quarter # => Wed, 01 Jul 2015 00:00:00 +0000 + def beginning_of_quarter + first_quarter_month = [10, 7, 4, 1].detect { |m| m <= month } + beginning_of_month.change(month: first_quarter_month) + end + alias :at_beginning_of_quarter :beginning_of_quarter + + # Returns a new date/time at the end of the quarter. + # + # today = Date.today # => Fri, 10 Jul 2015 + # today.end_of_quarter # => Wed, 30 Sep 2015 + # + # +DateTime+ objects will have a time set to 23:59:59. + # + # now = DateTime.current # => Fri, 10 Jul 2015 18:41:29 +0000 + # now.end_of_quarter # => Wed, 30 Sep 2015 23:59:59 +0000 + def end_of_quarter + last_quarter_month = [3, 6, 9, 12].detect { |m| m >= month } + beginning_of_month.change(month: last_quarter_month).end_of_month + end + alias :at_end_of_quarter :end_of_quarter + + # Returns a new date/time at the beginning of the year. + # + # today = Date.today # => Fri, 10 Jul 2015 + # today.beginning_of_year # => Thu, 01 Jan 2015 + # + # +DateTime+ objects will have a time set to 0:00. + # + # now = DateTime.current # => Fri, 10 Jul 2015 18:41:29 +0000 + # now.beginning_of_year # => Thu, 01 Jan 2015 00:00:00 +0000 + def beginning_of_year + change(month: 1).beginning_of_month + end + alias :at_beginning_of_year :beginning_of_year + + # Returns a new date/time representing the given day in the next week. + # + # today = Date.today # => Thu, 07 May 2015 + # today.next_week # => Mon, 11 May 2015 + # + # The +given_day_in_next_week+ defaults to the beginning of the week + # which is determined by +Date.beginning_of_week+ or +config.beginning_of_week+ + # when set. + # + # today = Date.today # => Thu, 07 May 2015 + # today.next_week(:friday) # => Fri, 15 May 2015 + # + # +DateTime+ objects have their time set to 0:00 unless +same_time+ is true. + # + # now = DateTime.current # => Thu, 07 May 2015 13:31:16 +0000 + # now.next_week # => Mon, 11 May 2015 00:00:00 +0000 + def next_week(given_day_in_next_week = Date.beginning_of_week, same_time: false) + result = first_hour(weeks_since(1).beginning_of_week.days_since(days_span(given_day_in_next_week))) + same_time ? copy_time_to(result) : result + end + + # Returns a new date/time representing the next weekday. + def next_weekday + if next_day.on_weekend? + next_week(:monday, same_time: true) + else + next_day + end + end + + # Returns a new date/time the specified number of months in the future. + def next_month(months = 1) + advance(months: months) + end + + # Short-hand for months_since(3) + def next_quarter + months_since(3) + end + + # Returns a new date/time the specified number of years in the future. + def next_year(years = 1) + advance(years: years) + end + + # Returns a new date/time representing the given day in the previous week. + # Week is assumed to start on +start_day+, default is + # +Date.beginning_of_week+ or +config.beginning_of_week+ when set. + # DateTime objects have their time set to 0:00 unless +same_time+ is true. + def prev_week(start_day = Date.beginning_of_week, same_time: false) + result = first_hour(weeks_ago(1).beginning_of_week.days_since(days_span(start_day))) + same_time ? copy_time_to(result) : result + end + alias_method :last_week, :prev_week + + # Returns a new date/time representing the previous weekday. + def prev_weekday + if prev_day.on_weekend? + copy_time_to(beginning_of_week(:friday)) + else + prev_day + end + end + alias_method :last_weekday, :prev_weekday + + # Returns a new date/time the specified number of months ago. + def prev_month(months = 1) + advance(months: -months) + end + + # Short-hand for months_ago(1). + def last_month + months_ago(1) + end + + # Short-hand for months_ago(3). + def prev_quarter + months_ago(3) + end + alias_method :last_quarter, :prev_quarter + + # Returns a new date/time the specified number of years ago. + def prev_year(years = 1) + advance(years: -years) + end + + # Short-hand for years_ago(1). + def last_year + years_ago(1) + end + + # Returns the number of days to the start of the week on the given day. + # Week is assumed to start on +start_day+, default is + # +Date.beginning_of_week+ or +config.beginning_of_week+ when set. + def days_to_week_start(start_day = Date.beginning_of_week) + start_day_number = DAYS_INTO_WEEK.fetch(start_day) + (wday - start_day_number) % 7 + end + + # Returns a new date/time representing the start of this week on the given day. + # Week is assumed to start on +start_day+, default is + # +Date.beginning_of_week+ or +config.beginning_of_week+ when set. + # +DateTime+ objects have their time set to 0:00. + def beginning_of_week(start_day = Date.beginning_of_week) + result = days_ago(days_to_week_start(start_day)) + acts_like?(:time) ? result.midnight : result + end + alias :at_beginning_of_week :beginning_of_week + + # Returns Monday of this week assuming that week starts on Monday. + # +DateTime+ objects have their time set to 0:00. + def monday + beginning_of_week(:monday) + end + + # Returns a new date/time representing the end of this week on the given day. + # Week is assumed to start on +start_day+, default is + # +Date.beginning_of_week+ or +config.beginning_of_week+ when set. + # DateTime objects have their time set to 23:59:59. + def end_of_week(start_day = Date.beginning_of_week) + last_hour(days_since(6 - days_to_week_start(start_day))) + end + alias :at_end_of_week :end_of_week + + # Returns Sunday of this week assuming that week starts on Monday. + # +DateTime+ objects have their time set to 23:59:59. + def sunday + end_of_week(:monday) + end + + # Returns a new date/time representing the end of the month. + # DateTime objects will have a time set to 23:59:59. + def end_of_month + last_day = ::Time.days_in_month(month, year) + last_hour(days_since(last_day - day)) + end + alias :at_end_of_month :end_of_month + + # Returns a new date/time representing the end of the year. + # DateTime objects will have a time set to 23:59:59. + def end_of_year + change(month: 12).end_of_month + end + alias :at_end_of_year :end_of_year + + # Returns a Range representing the whole day of the current date/time. + def all_day + beginning_of_day..end_of_day + end + + # Returns a Range representing the whole week of the current date/time. + # Week starts on start_day, default is <tt>Date.beginning_of_week</tt> or <tt>config.beginning_of_week</tt> when set. + def all_week(start_day = Date.beginning_of_week) + beginning_of_week(start_day)..end_of_week(start_day) + end + + # Returns a Range representing the whole month of the current date/time. + def all_month + beginning_of_month..end_of_month + end + + # Returns a Range representing the whole quarter of the current date/time. + def all_quarter + beginning_of_quarter..end_of_quarter + end + + # Returns a Range representing the whole year of the current date/time. + def all_year + beginning_of_year..end_of_year + end + + # Returns a new date/time representing the next occurrence of the specified day of week. + # + # today = Date.today # => Thu, 14 Dec 2017 + # today.next_occurring(:monday) # => Mon, 18 Dec 2017 + # today.next_occurring(:thursday) # => Thu, 21 Dec 2017 + def next_occurring(day_of_week) + from_now = DAYS_INTO_WEEK.fetch(day_of_week) - wday + from_now += 7 unless from_now > 0 + advance(days: from_now) + end + + # Returns a new date/time representing the previous occurrence of the specified day of week. + # + # today = Date.today # => Thu, 14 Dec 2017 + # today.prev_occurring(:monday) # => Mon, 11 Dec 2017 + # today.prev_occurring(:thursday) # => Thu, 07 Dec 2017 + def prev_occurring(day_of_week) + ago = wday - DAYS_INTO_WEEK.fetch(day_of_week) + ago += 7 unless ago > 0 + advance(days: -ago) + end + + private + def first_hour(date_or_time) + date_or_time.acts_like?(:time) ? date_or_time.beginning_of_day : date_or_time + end + + def last_hour(date_or_time) + date_or_time.acts_like?(:time) ? date_or_time.end_of_day : date_or_time + end + + def days_span(day) + (DAYS_INTO_WEEK.fetch(day) - DAYS_INTO_WEEK.fetch(Date.beginning_of_week)) % 7 + end + + def copy_time_to(other) + other.change(hour: hour, min: min, sec: sec, nsec: try(:nsec)) + end + end +end diff --git a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb new file mode 100644 index 0000000000..d33c36ef73 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" + +module DateAndTime + module Compatibility + # If true, +to_time+ preserves the timezone offset of receiver. + # + # NOTE: With Ruby 2.4+ the default for +to_time+ changed from + # converting to the local system time, to preserving the offset + # of the receiver. For backwards compatibility we're overriding + # this behavior, but new apps will have an initializer that sets + # this to true, because the new behavior is preferred. + mattr_accessor :preserve_timezone, instance_writer: false, default: false + end +end diff --git a/activesupport/lib/active_support/core_ext/date_and_time/zones.rb b/activesupport/lib/active_support/core_ext/date_and_time/zones.rb new file mode 100644 index 0000000000..894fd9b76d --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_and_time/zones.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module DateAndTime + module Zones + # Returns the simultaneous time in <tt>Time.zone</tt> if a zone is given or + # if Time.zone_default is set. Otherwise, it returns the current time. + # + # Time.zone = 'Hawaii' # => 'Hawaii' + # Time.utc(2000).in_time_zone # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # Date.new(2000).in_time_zone # => Sat, 01 Jan 2000 00:00:00 HST -10:00 + # + # This method is similar to Time#localtime, except that it uses <tt>Time.zone</tt> as the local zone + # instead of the operating system's time zone. + # + # You can also pass in a TimeZone instance or string that identifies a TimeZone as an argument, + # and the conversion will be based on that zone instead of <tt>Time.zone</tt>. + # + # Time.utc(2000).in_time_zone('Alaska') # => Fri, 31 Dec 1999 15:00:00 AKST -09:00 + # Date.new(2000).in_time_zone('Alaska') # => Sat, 01 Jan 2000 00:00:00 AKST -09:00 + def in_time_zone(zone = ::Time.zone) + time_zone = ::Time.find_zone! zone + time = acts_like?(:time) ? self : nil + + if time_zone + time_with_zone(time, time_zone) + else + time || to_time + end + end + + private + + def time_with_zone(time, zone) + if time + ActiveSupport::TimeWithZone.new(time.utc? ? time : time.getutc, zone) + else + ActiveSupport::TimeWithZone.new(nil, zone, to_time(:utc)) + end + end + end +end diff --git a/activesupport/lib/active_support/core_ext/date_time.rb b/activesupport/lib/active_support/core_ext/date_time.rb new file mode 100644 index 0000000000..790dbeec1b --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_time.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "active_support/core_ext/date_time/acts_like" +require "active_support/core_ext/date_time/blank" +require "active_support/core_ext/date_time/calculations" +require "active_support/core_ext/date_time/compatibility" +require "active_support/core_ext/date_time/conversions" diff --git a/activesupport/lib/active_support/core_ext/date_time/acts_like.rb b/activesupport/lib/active_support/core_ext/date_time/acts_like.rb new file mode 100644 index 0000000000..5dccdfe219 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_time/acts_like.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "date" +require "active_support/core_ext/object/acts_like" + +class DateTime + # Duck-types as a Date-like class. See Object#acts_like?. + def acts_like_date? + true + end + + # Duck-types as a Time-like class. See Object#acts_like?. + def acts_like_time? + true + end +end diff --git a/activesupport/lib/active_support/core_ext/date_time/blank.rb b/activesupport/lib/active_support/core_ext/date_time/blank.rb new file mode 100644 index 0000000000..a52c8bc150 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_time/blank.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "date" + +class DateTime #:nodoc: + # No DateTime is ever blank: + # + # DateTime.now.blank? # => false + # + # @return [false] + def blank? + false + end +end diff --git a/activesupport/lib/active_support/core_ext/date_time/calculations.rb b/activesupport/lib/active_support/core_ext/date_time/calculations.rb new file mode 100644 index 0000000000..bc670c3e76 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_time/calculations.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require "date" + +class DateTime + class << self + # Returns <tt>Time.zone.now.to_datetime</tt> when <tt>Time.zone</tt> or + # <tt>config.time_zone</tt> are set, otherwise returns + # <tt>Time.now.to_datetime</tt>. + def current + ::Time.zone ? ::Time.zone.now.to_datetime : ::Time.now.to_datetime + end + end + + # Returns the number of seconds since 00:00:00. + # + # DateTime.new(2012, 8, 29, 0, 0, 0).seconds_since_midnight # => 0 + # DateTime.new(2012, 8, 29, 12, 34, 56).seconds_since_midnight # => 45296 + # DateTime.new(2012, 8, 29, 23, 59, 59).seconds_since_midnight # => 86399 + def seconds_since_midnight + sec + (min * 60) + (hour * 3600) + end + + # Returns the number of seconds until 23:59:59. + # + # DateTime.new(2012, 8, 29, 0, 0, 0).seconds_until_end_of_day # => 86399 + # DateTime.new(2012, 8, 29, 12, 34, 56).seconds_until_end_of_day # => 41103 + # DateTime.new(2012, 8, 29, 23, 59, 59).seconds_until_end_of_day # => 0 + def seconds_until_end_of_day + end_of_day.to_i - to_i + end + + # Returns the fraction of a second as a +Rational+ + # + # DateTime.new(2012, 8, 29, 0, 0, 0.5).subsec # => (1/2) + def subsec + sec_fraction + end + + # Returns a new DateTime where one or more of the elements have been changed + # according to the +options+ parameter. The time options (<tt>:hour</tt>, + # <tt>:min</tt>, <tt>:sec</tt>) reset cascadingly, so if only the hour is + # passed, then minute and sec is set to 0. If the hour and minute is passed, + # then sec is set to 0. The +options+ parameter takes a hash with any of these + # keys: <tt>:year</tt>, <tt>:month</tt>, <tt>:day</tt>, <tt>:hour</tt>, + # <tt>:min</tt>, <tt>:sec</tt>, <tt>:offset</tt>, <tt>:start</tt>. + # + # DateTime.new(2012, 8, 29, 22, 35, 0).change(day: 1) # => DateTime.new(2012, 8, 1, 22, 35, 0) + # DateTime.new(2012, 8, 29, 22, 35, 0).change(year: 1981, day: 1) # => DateTime.new(1981, 8, 1, 22, 35, 0) + # DateTime.new(2012, 8, 29, 22, 35, 0).change(year: 1981, hour: 0) # => DateTime.new(1981, 8, 29, 0, 0, 0) + def change(options) + if new_nsec = options[:nsec] + raise ArgumentError, "Can't change both :nsec and :usec at the same time: #{options.inspect}" if options[:usec] + new_fraction = Rational(new_nsec, 1000000000) + else + new_usec = options.fetch(:usec, (options[:hour] || options[:min] || options[:sec]) ? 0 : Rational(nsec, 1000)) + new_fraction = Rational(new_usec, 1000000) + end + + raise ArgumentError, "argument out of range" if new_fraction >= 1 + + ::DateTime.civil( + options.fetch(:year, year), + options.fetch(:month, month), + options.fetch(:day, day), + options.fetch(:hour, hour), + options.fetch(:min, options[:hour] ? 0 : min), + options.fetch(:sec, (options[:hour] || options[:min]) ? 0 : sec) + new_fraction, + options.fetch(:offset, offset), + options.fetch(:start, start) + ) + end + + # Uses Date to provide precise Time calculations for years, months, and days. + # The +options+ parameter takes a hash with any of these keys: <tt>:years</tt>, + # <tt>:months</tt>, <tt>:weeks</tt>, <tt>:days</tt>, <tt>:hours</tt>, + # <tt>:minutes</tt>, <tt>:seconds</tt>. + def advance(options) + unless options[:weeks].nil? + options[:weeks], partial_weeks = options[:weeks].divmod(1) + options[:days] = options.fetch(:days, 0) + 7 * partial_weeks + end + + unless options[:days].nil? + options[:days], partial_days = options[:days].divmod(1) + options[:hours] = options.fetch(:hours, 0) + 24 * partial_days + end + + d = to_date.advance(options) + datetime_advanced_by_date = change(year: d.year, month: d.month, day: d.day) + seconds_to_advance = \ + options.fetch(:seconds, 0) + + options.fetch(:minutes, 0) * 60 + + options.fetch(:hours, 0) * 3600 + + if seconds_to_advance.zero? + datetime_advanced_by_date + else + datetime_advanced_by_date.since(seconds_to_advance) + end + end + + # Returns a new DateTime representing the time a number of seconds ago. + # Do not use this method in combination with x.months, use months_ago instead! + def ago(seconds) + since(-seconds) + end + + # Returns a new DateTime representing the time a number of seconds since the + # instance time. Do not use this method in combination with x.months, use + # months_since instead! + def since(seconds) + self + Rational(seconds, 86400) + end + alias :in :since + + # Returns a new DateTime representing the start of the day (0:00). + def beginning_of_day + change(hour: 0) + end + alias :midnight :beginning_of_day + alias :at_midnight :beginning_of_day + alias :at_beginning_of_day :beginning_of_day + + # Returns a new DateTime representing the middle of the day (12:00) + def middle_of_day + change(hour: 12) + end + alias :midday :middle_of_day + alias :noon :middle_of_day + alias :at_midday :middle_of_day + alias :at_noon :middle_of_day + alias :at_middle_of_day :middle_of_day + + # Returns a new DateTime representing the end of the day (23:59:59). + def end_of_day + change(hour: 23, min: 59, sec: 59, usec: Rational(999999999, 1000)) + end + alias :at_end_of_day :end_of_day + + # Returns a new DateTime representing the start of the hour (hh:00:00). + def beginning_of_hour + change(min: 0) + end + alias :at_beginning_of_hour :beginning_of_hour + + # Returns a new DateTime representing the end of the hour (hh:59:59). + def end_of_hour + change(min: 59, sec: 59, usec: Rational(999999999, 1000)) + end + alias :at_end_of_hour :end_of_hour + + # Returns a new DateTime representing the start of the minute (hh:mm:00). + def beginning_of_minute + change(sec: 0) + end + alias :at_beginning_of_minute :beginning_of_minute + + # Returns a new DateTime representing the end of the minute (hh:mm:59). + def end_of_minute + change(sec: 59, usec: Rational(999999999, 1000)) + end + alias :at_end_of_minute :end_of_minute + + # Returns a <tt>Time</tt> instance of the simultaneous time in the system timezone. + def localtime(utc_offset = nil) + utc = new_offset(0) + + Time.utc( + utc.year, utc.month, utc.day, + utc.hour, utc.min, utc.sec + utc.sec_fraction + ).getlocal(utc_offset) + end + alias_method :getlocal, :localtime + + # Returns a <tt>Time</tt> instance of the simultaneous time in the UTC timezone. + # + # DateTime.civil(2005, 2, 21, 10, 11, 12, Rational(-6, 24)) # => Mon, 21 Feb 2005 10:11:12 -0600 + # DateTime.civil(2005, 2, 21, 10, 11, 12, Rational(-6, 24)).utc # => Mon, 21 Feb 2005 16:11:12 UTC + def utc + utc = new_offset(0) + + Time.utc( + utc.year, utc.month, utc.day, + utc.hour, utc.min, utc.sec + utc.sec_fraction + ) + end + alias_method :getgm, :utc + alias_method :getutc, :utc + alias_method :gmtime, :utc + + # Returns +true+ if <tt>offset == 0</tt>. + def utc? + offset == 0 + end + + # Returns the offset value in seconds. + def utc_offset + (offset * 86400).to_i + end + + # Layers additional behavior on DateTime#<=> so that Time and + # ActiveSupport::TimeWithZone instances can be compared with a DateTime. + def <=>(other) + if other.respond_to? :to_datetime + super other.to_datetime rescue nil + else + super + end + end +end diff --git a/activesupport/lib/active_support/core_ext/date_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb new file mode 100644 index 0000000000..7600a067cc --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "active_support/core_ext/date_and_time/compatibility" +require "active_support/core_ext/module/redefine_method" + +class DateTime + include DateAndTime::Compatibility + + silence_redefinition_of_method :to_time + + # Either return an instance of +Time+ with the same UTC offset + # as +self+ or an instance of +Time+ representing the same time + # in the local system timezone depending on the setting of + # on the setting of +ActiveSupport.to_time_preserves_timezone+. + def to_time + preserve_timezone ? getlocal(utc_offset) : getlocal + end +end diff --git a/activesupport/lib/active_support/core_ext/date_time/conversions.rb b/activesupport/lib/active_support/core_ext/date_time/conversions.rb new file mode 100644 index 0000000000..29725c89f7 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_time/conversions.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "date" +require "active_support/inflector/methods" +require "active_support/core_ext/time/conversions" +require "active_support/core_ext/date_time/calculations" +require "active_support/values/time_zone" + +class DateTime + # Convert to a formatted string. See Time::DATE_FORMATS for predefined formats. + # + # This method is aliased to <tt>to_s</tt>. + # + # === Examples + # datetime = DateTime.civil(2007, 12, 4, 0, 0, 0, 0) # => Tue, 04 Dec 2007 00:00:00 +0000 + # + # datetime.to_formatted_s(:db) # => "2007-12-04 00:00:00" + # datetime.to_s(:db) # => "2007-12-04 00:00:00" + # datetime.to_s(:number) # => "20071204000000" + # datetime.to_formatted_s(:short) # => "04 Dec 00:00" + # datetime.to_formatted_s(:long) # => "December 04, 2007 00:00" + # datetime.to_formatted_s(:long_ordinal) # => "December 4th, 2007 00:00" + # datetime.to_formatted_s(:rfc822) # => "Tue, 04 Dec 2007 00:00:00 +0000" + # datetime.to_formatted_s(:iso8601) # => "2007-12-04T00:00:00+00:00" + # + # == Adding your own datetime formats to to_formatted_s + # DateTime formats are shared with Time. You can add your own to the + # Time::DATE_FORMATS hash. Use the format name as the hash key and + # either a strftime string or Proc instance that takes a time or + # datetime argument as the value. + # + # # config/initializers/time_formats.rb + # Time::DATE_FORMATS[:month_and_year] = '%B %Y' + # Time::DATE_FORMATS[:short_ordinal] = lambda { |time| time.strftime("%B #{time.day.ordinalize}") } + def to_formatted_s(format = :default) + if formatter = ::Time::DATE_FORMATS[format] + formatter.respond_to?(:call) ? formatter.call(self).to_s : strftime(formatter) + else + to_default_s + end + end + alias_method :to_default_s, :to_s if instance_methods(false).include?(:to_s) + alias_method :to_s, :to_formatted_s + + # Returns a formatted string of the offset from UTC, or an alternative + # string if the time zone is already UTC. + # + # datetime = DateTime.civil(2000, 1, 1, 0, 0, 0, Rational(-6, 24)) + # datetime.formatted_offset # => "-06:00" + # datetime.formatted_offset(false) # => "-0600" + def formatted_offset(colon = true, alternate_utc_string = nil) + utc? && alternate_utc_string || ActiveSupport::TimeZone.seconds_to_utc_offset(utc_offset, colon) + end + + # Overrides the default inspect method with a human readable one, e.g., "Mon, 21 Feb 2005 14:30:00 +0000". + def readable_inspect + to_s(:rfc822) + end + alias_method :default_inspect, :inspect + alias_method :inspect, :readable_inspect + + # Returns DateTime with local offset for given year if format is local else + # offset is zero. + # + # DateTime.civil_from_format :local, 2012 + # # => Sun, 01 Jan 2012 00:00:00 +0300 + # DateTime.civil_from_format :local, 2012, 12, 17 + # # => Mon, 17 Dec 2012 00:00:00 +0000 + def self.civil_from_format(utc_or_local, year, month = 1, day = 1, hour = 0, min = 0, sec = 0) + if utc_or_local.to_sym == :local + offset = ::Time.local(year, month, day).utc_offset.to_r / 86400 + else + offset = 0 + end + civil(year, month, day, hour, min, sec, offset) + end + + # Converts +self+ to a floating-point number of seconds, including fractional microseconds, since the Unix epoch. + def to_f + seconds_since_unix_epoch.to_f + sec_fraction + end + + # Converts +self+ to an integer number of seconds since the Unix epoch. + def to_i + seconds_since_unix_epoch.to_i + end + + # Returns the fraction of a second as microseconds + def usec + (sec_fraction * 1_000_000).to_i + end + + # Returns the fraction of a second as nanoseconds + def nsec + (sec_fraction * 1_000_000_000).to_i + end + + private + + def offset_in_seconds + (offset * 86400).to_i + end + + def seconds_since_unix_epoch + (jd - 2440588) * 86400 - offset_in_seconds + seconds_since_midnight + end +end diff --git a/activesupport/lib/active_support/core_ext/digest/uuid.rb b/activesupport/lib/active_support/core_ext/digest/uuid.rb new file mode 100644 index 0000000000..6e949a2d72 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/digest/uuid.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "securerandom" + +module Digest + module UUID + DNS_NAMESPACE = "k\xA7\xB8\x10\x9D\xAD\x11\xD1\x80\xB4\x00\xC0O\xD40\xC8" #:nodoc: + URL_NAMESPACE = "k\xA7\xB8\x11\x9D\xAD\x11\xD1\x80\xB4\x00\xC0O\xD40\xC8" #:nodoc: + OID_NAMESPACE = "k\xA7\xB8\x12\x9D\xAD\x11\xD1\x80\xB4\x00\xC0O\xD40\xC8" #:nodoc: + X500_NAMESPACE = "k\xA7\xB8\x14\x9D\xAD\x11\xD1\x80\xB4\x00\xC0O\xD40\xC8" #:nodoc: + + # Generates a v5 non-random UUID (Universally Unique IDentifier). + # + # Using Digest::MD5 generates version 3 UUIDs; Digest::SHA1 generates version 5 UUIDs. + # uuid_from_hash always generates the same UUID for a given name and namespace combination. + # + # See RFC 4122 for details of UUID at: https://www.ietf.org/rfc/rfc4122.txt + def self.uuid_from_hash(hash_class, uuid_namespace, name) + if hash_class == Digest::MD5 + version = 3 + elsif hash_class == Digest::SHA1 + version = 5 + else + raise ArgumentError, "Expected Digest::SHA1 or Digest::MD5, got #{hash_class.name}." + end + + hash = hash_class.new + hash.update(uuid_namespace) + hash.update(name) + + ary = hash.digest.unpack("NnnnnN") + ary[2] = (ary[2] & 0x0FFF) | (version << 12) + ary[3] = (ary[3] & 0x3FFF) | 0x8000 + + "%08x-%04x-%04x-%04x-%04x%08x" % ary + end + + # Convenience method for uuid_from_hash using Digest::MD5. + def self.uuid_v3(uuid_namespace, name) + uuid_from_hash(Digest::MD5, uuid_namespace, name) + end + + # Convenience method for uuid_from_hash using Digest::SHA1. + def self.uuid_v5(uuid_namespace, name) + uuid_from_hash(Digest::SHA1, uuid_namespace, name) + end + + # Convenience method for SecureRandom.uuid. + def self.uuid_v4 + SecureRandom.uuid + end + end +end diff --git a/activesupport/lib/active_support/core_ext/enumerable.rb b/activesupport/lib/active_support/core_ext/enumerable.rb new file mode 100644 index 0000000000..d87d63f287 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/enumerable.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +module Enumerable + INDEX_WITH_DEFAULT = Object.new + private_constant :INDEX_WITH_DEFAULT + + # Enumerable#sum was added in Ruby 2.4, but it only works with Numeric elements + # when we omit an identity. + + # :stopdoc: + + # We can't use Refinements here because Refinements with Module which will be prepended + # doesn't work well https://bugs.ruby-lang.org/issues/13446 + alias :_original_sum_with_required_identity :sum + private :_original_sum_with_required_identity + + # :startdoc: + + # Calculates a sum from the elements. + # + # payments.sum { |p| p.price * p.tax_rate } + # payments.sum(&:price) + # + # The latter is a shortcut for: + # + # payments.inject(0) { |sum, p| sum + p.price } + # + # It can also calculate the sum without the use of a block. + # + # [5, 15, 10].sum # => 30 + # ['foo', 'bar'].sum # => "foobar" + # [[1, 2], [3, 1, 5]].sum # => [1, 2, 3, 1, 5] + # + # The default sum of an empty list is zero. You can override this default: + # + # [].sum(Payment.new(0)) { |i| i.amount } # => Payment.new(0) + def sum(identity = nil, &block) + if identity + _original_sum_with_required_identity(identity, &block) + elsif block_given? + map(&block).sum(identity) + else + inject(:+) || 0 + end + end + + # Convert an enumerable to a hash keying it by the block return value. + # + # people.index_by(&:login) + # # => { "nextangle" => <Person ...>, "chade-" => <Person ...>, ...} + # + # people.index_by { |person| "#{person.first_name} #{person.last_name}" } + # # => { "Chade- Fowlersburg-e" => <Person ...>, "David Heinemeier Hansson" => <Person ...>, ...} + def index_by + if block_given? + result = {} + each { |elem| result[yield(elem)] = elem } + result + else + to_enum(:index_by) { size if respond_to?(:size) } + end + end + + # Convert an enumerable to a hash keying it with the enumerable items and with the values returned in the block. + # + # post = Post.new(title: "hey there", body: "what's up?") + # + # %i( title body ).index_with { |attr_name| post.public_send(attr_name) } + # # => { title: "hey there", body: "what's up?" } + def index_with(default = INDEX_WITH_DEFAULT) + if block_given? + result = {} + each { |elem| result[elem] = yield(elem) } + result + elsif default != INDEX_WITH_DEFAULT + result = {} + each { |elem| result[elem] = default } + result + else + to_enum(:index_with) { size if respond_to?(:size) } + end + end + + # Returns +true+ if the enumerable has more than 1 element. Functionally + # equivalent to <tt>enum.to_a.size > 1</tt>. Can be called with a block too, + # much like any?, so <tt>people.many? { |p| p.age > 26 }</tt> returns +true+ + # if more than one person is over 26. + def many? + cnt = 0 + if block_given? + any? do |element| + cnt += 1 if yield element + cnt > 1 + end + else + any? { (cnt += 1) > 1 } + end + end + + # The negative of the <tt>Enumerable#include?</tt>. Returns +true+ if the + # collection does not include the object. + def exclude?(object) + !include?(object) + end + + # Returns a copy of the enumerable without the specified elements. + # + # ["David", "Rafael", "Aaron", "Todd"].without "Aaron", "Todd" + # # => ["David", "Rafael"] + # + # {foo: 1, bar: 2, baz: 3}.without :bar + # # => {foo: 1, baz: 3} + def without(*elements) + reject { |element| elements.include?(element) } + end + + # Convert an enumerable to an array based on the given key. + # + # [{ name: "David" }, { name: "Rafael" }, { name: "Aaron" }].pluck(:name) + # # => ["David", "Rafael", "Aaron"] + # + # [{ id: 1, name: "David" }, { id: 2, name: "Rafael" }].pluck(:id, :name) + # # => [[1, "David"], [2, "Rafael"]] + def pluck(*keys) + if keys.many? + map { |element| keys.map { |key| element[key] } } + else + map { |element| element[keys.first] } + end + end +end + +class Range #:nodoc: + # Optimize range sum to use arithmetic progression if a block is not given and + # we have a range of numeric values. + def sum(identity = nil) + if block_given? || !(first.is_a?(Integer) && last.is_a?(Integer)) + super + else + actual_last = exclude_end? ? (last - 1) : last + if actual_last >= first + sum = identity || 0 + sum + (actual_last - first + 1) * (actual_last + first) / 2 + else + identity || 0 + end + end + end +end + +# Using Refinements here in order not to expose our internal method +using Module.new { + refine Array do + alias :orig_sum :sum + end +} + +class Array #:nodoc: + # Array#sum was added in Ruby 2.4 but it only works with Numeric elements. + def sum(init = nil, &block) + if init.is_a?(Numeric) || first.is_a?(Numeric) + init ||= 0 + orig_sum(init, &block) + else + super + end + end +end diff --git a/activesupport/lib/active_support/core_ext/file.rb b/activesupport/lib/active_support/core_ext/file.rb new file mode 100644 index 0000000000..64553bfa4e --- /dev/null +++ b/activesupport/lib/active_support/core_ext/file.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "active_support/core_ext/file/atomic" diff --git a/activesupport/lib/active_support/core_ext/file/atomic.rb b/activesupport/lib/active_support/core_ext/file/atomic.rb new file mode 100644 index 0000000000..9deceb1bb4 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/file/atomic.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "fileutils" + +class File + # Write to a file atomically. Useful for situations where you don't + # want other processes or threads to see half-written files. + # + # File.atomic_write('important.file') do |file| + # file.write('hello') + # end + # + # This method needs to create a temporary file. By default it will create it + # in the same directory as the destination file. If you don't like this + # behavior you can provide a different directory but it must be on the + # same physical filesystem as the file you're trying to write. + # + # File.atomic_write('/data/something.important', '/data/tmp') do |file| + # file.write('hello') + # end + def self.atomic_write(file_name, temp_dir = dirname(file_name)) + require "tempfile" unless defined?(Tempfile) + + Tempfile.open(".#{basename(file_name)}", temp_dir) do |temp_file| + temp_file.binmode + return_val = yield temp_file + temp_file.close + + old_stat = if exist?(file_name) + # Get original file permissions + stat(file_name) + else + # If not possible, probe which are the default permissions in the + # destination directory. + probe_stat_in(dirname(file_name)) + end + + if old_stat + # Set correct permissions on new file + begin + chown(old_stat.uid, old_stat.gid, temp_file.path) + # This operation will affect filesystem ACL's + chmod(old_stat.mode, temp_file.path) + rescue Errno::EPERM, Errno::EACCES + # Changing file ownership failed, moving on. + end + end + + # Overwrite original file with temp file + rename(temp_file.path, file_name) + return_val + end + end + + # Private utility method. + def self.probe_stat_in(dir) #:nodoc: + basename = [ + ".permissions_check", + Thread.current.object_id, + Process.pid, + rand(1000000) + ].join(".") + + file_name = join(dir, basename) + FileUtils.touch(file_name) + stat(file_name) + ensure + FileUtils.rm_f(file_name) if file_name + end +end diff --git a/activesupport/lib/active_support/core_ext/hash.rb b/activesupport/lib/active_support/core_ext/hash.rb new file mode 100644 index 0000000000..c4b9e5f1a0 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/conversions" +require "active_support/core_ext/hash/deep_merge" +require "active_support/core_ext/hash/except" +require "active_support/core_ext/hash/indifferent_access" +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/core_ext/hash/slice" diff --git a/activesupport/lib/active_support/core_ext/hash/compact.rb b/activesupport/lib/active_support/core_ext/hash/compact.rb new file mode 100644 index 0000000000..5cb858af5c --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/compact.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_support/deprecation" + +ActiveSupport::Deprecation.warn "Ruby 2.5+ (required by Rails 6) provides Hash#compact and Hash#compact! natively, so requiring active_support/core_ext/hash/compact is no longer necessary. Requiring it will raise LoadError in Rails 6.1." diff --git a/activesupport/lib/active_support/core_ext/hash/conversions.rb b/activesupport/lib/active_support/core_ext/hash/conversions.rb new file mode 100644 index 0000000000..5b48254646 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/conversions.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +require "active_support/xml_mini" +require "active_support/time" +require "active_support/core_ext/object/blank" +require "active_support/core_ext/object/to_param" +require "active_support/core_ext/object/to_query" +require "active_support/core_ext/array/wrap" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/core_ext/string/inflections" + +class Hash + # Returns a string containing an XML representation of its receiver: + # + # { foo: 1, bar: 2 }.to_xml + # # => + # # <?xml version="1.0" encoding="UTF-8"?> + # # <hash> + # # <foo type="integer">1</foo> + # # <bar type="integer">2</bar> + # # </hash> + # + # To do so, the method loops over the pairs and builds nodes that depend on + # the _values_. Given a pair +key+, +value+: + # + # * If +value+ is a hash there's a recursive call with +key+ as <tt>:root</tt>. + # + # * If +value+ is an array there's a recursive call with +key+ as <tt>:root</tt>, + # and +key+ singularized as <tt>:children</tt>. + # + # * If +value+ is a callable object it must expect one or two arguments. Depending + # on the arity, the callable is invoked with the +options+ hash as first argument + # with +key+ as <tt>:root</tt>, and +key+ singularized as second argument. The + # callable can add nodes by using <tt>options[:builder]</tt>. + # + # {foo: lambda { |options, key| options[:builder].b(key) }}.to_xml + # # => "<b>foo</b>" + # + # * If +value+ responds to +to_xml+ the method is invoked with +key+ as <tt>:root</tt>. + # + # class Foo + # def to_xml(options) + # options[:builder].bar 'fooing!' + # end + # end + # + # { foo: Foo.new }.to_xml(skip_instruct: true) + # # => + # # <hash> + # # <bar>fooing!</bar> + # # </hash> + # + # * Otherwise, a node with +key+ as tag is created with a string representation of + # +value+ as text node. If +value+ is +nil+ an attribute "nil" set to "true" is added. + # Unless the option <tt>:skip_types</tt> exists and is true, an attribute "type" is + # added as well according to the following mapping: + # + # XML_TYPE_NAMES = { + # "Symbol" => "symbol", + # "Integer" => "integer", + # "BigDecimal" => "decimal", + # "Float" => "float", + # "TrueClass" => "boolean", + # "FalseClass" => "boolean", + # "Date" => "date", + # "DateTime" => "dateTime", + # "Time" => "dateTime" + # } + # + # By default the root node is "hash", but that's configurable via the <tt>:root</tt> option. + # + # The default XML builder is a fresh instance of <tt>Builder::XmlMarkup</tt>. You can + # configure your own builder with the <tt>:builder</tt> option. The method also accepts + # options like <tt>:dasherize</tt> and friends, they are forwarded to the builder. + def to_xml(options = {}) + require "active_support/builder" unless defined?(Builder) + + options = options.dup + options[:indent] ||= 2 + options[:root] ||= "hash" + options[:builder] ||= Builder::XmlMarkup.new(indent: options[:indent]) + + builder = options[:builder] + builder.instruct! unless options.delete(:skip_instruct) + + root = ActiveSupport::XmlMini.rename_key(options[:root].to_s, options) + + builder.tag!(root) do + each { |key, value| ActiveSupport::XmlMini.to_tag(key, value, options) } + yield builder if block_given? + end + end + + class << self + # Returns a Hash containing a collection of pairs when the key is the node name and the value is + # its content + # + # xml = <<-XML + # <?xml version="1.0" encoding="UTF-8"?> + # <hash> + # <foo type="integer">1</foo> + # <bar type="integer">2</bar> + # </hash> + # XML + # + # hash = Hash.from_xml(xml) + # # => {"hash"=>{"foo"=>1, "bar"=>2}} + # + # +DisallowedType+ is raised if the XML contains attributes with <tt>type="yaml"</tt> or + # <tt>type="symbol"</tt>. Use <tt>Hash.from_trusted_xml</tt> to + # parse this XML. + # + # Custom +disallowed_types+ can also be passed in the form of an + # array. + # + # xml = <<-XML + # <?xml version="1.0" encoding="UTF-8"?> + # <hash> + # <foo type="integer">1</foo> + # <bar type="string">"David"</bar> + # </hash> + # XML + # + # hash = Hash.from_xml(xml, ['integer']) + # # => ActiveSupport::XMLConverter::DisallowedType: Disallowed type attribute: "integer" + # + # Note that passing custom disallowed types will override the default types, + # which are Symbol and YAML. + def from_xml(xml, disallowed_types = nil) + ActiveSupport::XMLConverter.new(xml, disallowed_types).to_h + end + + # Builds a Hash from XML just like <tt>Hash.from_xml</tt>, but also allows Symbol and YAML. + def from_trusted_xml(xml) + from_xml xml, [] + end + end +end + +module ActiveSupport + class XMLConverter # :nodoc: + # Raised if the XML contains attributes with type="yaml" or + # type="symbol". Read Hash#from_xml for more details. + class DisallowedType < StandardError + def initialize(type) + super "Disallowed type attribute: #{type.inspect}" + end + end + + DISALLOWED_TYPES = %w(symbol yaml) + + def initialize(xml, disallowed_types = nil) + @xml = normalize_keys(XmlMini.parse(xml)) + @disallowed_types = disallowed_types || DISALLOWED_TYPES + end + + def to_h + deep_to_h(@xml) + end + + private + def normalize_keys(params) + case params + when Hash + Hash[params.map { |k, v| [k.to_s.tr("-", "_"), normalize_keys(v)] } ] + when Array + params.map { |v| normalize_keys(v) } + else + params + end + end + + def deep_to_h(value) + case value + when Hash + process_hash(value) + when Array + process_array(value) + when String + value + else + raise "can't typecast #{value.class.name} - #{value.inspect}" + end + end + + def process_hash(value) + if value.include?("type") && !value["type"].is_a?(Hash) && @disallowed_types.include?(value["type"]) + raise DisallowedType, value["type"] + end + + if become_array?(value) + _, entries = Array.wrap(value.detect { |k, v| not v.is_a?(String) }) + if entries.nil? || value["__content__"].try(:empty?) + [] + else + case entries + when Array + entries.collect { |v| deep_to_h(v) } + when Hash + [deep_to_h(entries)] + else + raise "can't typecast #{entries.inspect}" + end + end + elsif become_content?(value) + process_content(value) + + elsif become_empty_string?(value) + "" + elsif become_hash?(value) + xml_value = Hash[value.map { |k, v| [k, deep_to_h(v)] }] + + # Turn { files: { file: #<StringIO> } } into { files: #<StringIO> } so it is compatible with + # how multipart uploaded files from HTML appear + xml_value["file"].is_a?(StringIO) ? xml_value["file"] : xml_value + end + end + + def become_content?(value) + value["type"] == "file" || (value["__content__"] && (value.keys.size == 1 || value["__content__"].present?)) + end + + def become_array?(value) + value["type"] == "array" + end + + def become_empty_string?(value) + # { "string" => true } + # No tests fail when the second term is removed. + value["type"] == "string" && value["nil"] != "true" + end + + def become_hash?(value) + !nothing?(value) && !garbage?(value) + end + + def nothing?(value) + # blank or nil parsed values are represented by nil + value.blank? || value["nil"] == "true" + end + + def garbage?(value) + # If the type is the only element which makes it then + # this still makes the value nil, except if type is + # an XML node(where type['value'] is a Hash) + value["type"] && !value["type"].is_a?(::Hash) && value.size == 1 + end + + def process_content(value) + content = value["__content__"] + if parser = ActiveSupport::XmlMini::PARSING[value["type"]] + parser.arity == 1 ? parser.call(content) : parser.call(content, value) + else + content + end + end + + def process_array(value) + value.map! { |i| deep_to_h(i) } + value.length > 1 ? value : value.first + end + end +end diff --git a/activesupport/lib/active_support/core_ext/hash/deep_merge.rb b/activesupport/lib/active_support/core_ext/hash/deep_merge.rb new file mode 100644 index 0000000000..9bc50b7bc6 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/deep_merge.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Hash + # Returns a new hash with +self+ and +other_hash+ merged recursively. + # + # h1 = { a: true, b: { c: [1, 2, 3] } } + # h2 = { a: false, b: { x: [3, 4, 5] } } + # + # h1.deep_merge(h2) # => { a: false, b: { c: [1, 2, 3], x: [3, 4, 5] } } + # + # Like with Hash#merge in the standard library, a block can be provided + # to merge values: + # + # h1 = { a: 100, b: 200, c: { c1: 100 } } + # h2 = { b: 250, c: { c1: 200 } } + # h1.deep_merge(h2) { |key, this_val, other_val| this_val + other_val } + # # => { a: 100, b: 450, c: { c1: 300 } } + def deep_merge(other_hash, &block) + dup.deep_merge!(other_hash, &block) + end + + # Same as +deep_merge+, but modifies +self+. + def deep_merge!(other_hash, &block) + merge!(other_hash) do |key, this_val, other_val| + if this_val.is_a?(Hash) && other_val.is_a?(Hash) + this_val.deep_merge(other_val, &block) + elsif block_given? + block.call(key, this_val, other_val) + else + other_val + end + end + end +end diff --git a/activesupport/lib/active_support/core_ext/hash/except.rb b/activesupport/lib/active_support/core_ext/hash/except.rb new file mode 100644 index 0000000000..6258610c98 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/except.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Hash + # Returns a hash that includes everything except given keys. + # hash = { a: true, b: false, c: nil } + # hash.except(:c) # => { a: true, b: false } + # hash.except(:a, :b) # => { c: nil } + # hash # => { a: true, b: false, c: nil } + # + # This is useful for limiting a set of parameters to everything but a few known toggles: + # @person.update(params[:person].except(:admin)) + def except(*keys) + dup.except!(*keys) + end + + # Removes the given keys from hash and returns it. + # hash = { a: true, b: false, c: nil } + # hash.except!(:c) # => { a: true, b: false } + # hash # => { a: true, b: false } + def except!(*keys) + keys.each { |key| delete(key) } + self + end +end diff --git a/activesupport/lib/active_support/core_ext/hash/indifferent_access.rb b/activesupport/lib/active_support/core_ext/hash/indifferent_access.rb new file mode 100644 index 0000000000..a38f33f128 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/indifferent_access.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "active_support/hash_with_indifferent_access" + +class Hash + # Returns an <tt>ActiveSupport::HashWithIndifferentAccess</tt> out of its receiver: + # + # { a: 1 }.with_indifferent_access['a'] # => 1 + def with_indifferent_access + ActiveSupport::HashWithIndifferentAccess.new(self) + end + + # Called when object is nested under an object that receives + # #with_indifferent_access. This method will be called on the current object + # by the enclosing object and is aliased to #with_indifferent_access by + # default. Subclasses of Hash may overwrite this method to return +self+ if + # converting to an <tt>ActiveSupport::HashWithIndifferentAccess</tt> would not be + # desirable. + # + # b = { b: 1 } + # { a: b }.with_indifferent_access['a'] # calls b.nested_under_indifferent_access + # # => {"b"=>1} + alias nested_under_indifferent_access with_indifferent_access +end diff --git a/activesupport/lib/active_support/core_ext/hash/keys.rb b/activesupport/lib/active_support/core_ext/hash/keys.rb new file mode 100644 index 0000000000..7d3495db2c --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/keys.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +class Hash + # Returns a new hash with all keys converted to strings. + # + # hash = { name: 'Rob', age: '28' } + # + # hash.stringify_keys + # # => {"name"=>"Rob", "age"=>"28"} + def stringify_keys + transform_keys(&:to_s) + end + + # Destructively converts all keys to strings. Same as + # +stringify_keys+, but modifies +self+. + def stringify_keys! + transform_keys!(&:to_s) + end + + # Returns a new hash with all keys converted to symbols, as long as + # they respond to +to_sym+. + # + # hash = { 'name' => 'Rob', 'age' => '28' } + # + # hash.symbolize_keys + # # => {:name=>"Rob", :age=>"28"} + def symbolize_keys + transform_keys { |key| key.to_sym rescue key } + end + alias_method :to_options, :symbolize_keys + + # Destructively converts all keys to symbols, as long as they respond + # to +to_sym+. Same as +symbolize_keys+, but modifies +self+. + def symbolize_keys! + transform_keys! { |key| key.to_sym rescue key } + end + alias_method :to_options!, :symbolize_keys! + + # Validates all keys in a hash match <tt>*valid_keys</tt>, raising + # +ArgumentError+ on a mismatch. + # + # Note that keys are treated differently than HashWithIndifferentAccess, + # meaning that string and symbol keys will not match. + # + # { name: 'Rob', years: '28' }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key: :years. Valid keys are: :name, :age" + # { name: 'Rob', age: '28' }.assert_valid_keys('name', 'age') # => raises "ArgumentError: Unknown key: :name. Valid keys are: 'name', 'age'" + # { name: 'Rob', age: '28' }.assert_valid_keys(:name, :age) # => passes, raises nothing + def assert_valid_keys(*valid_keys) + valid_keys.flatten! + each_key do |k| + unless valid_keys.include?(k) + raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{valid_keys.map(&:inspect).join(', ')}") + end + end + end + + # Returns a new hash with all keys converted by the block operation. + # This includes the keys from the root hash and from all + # nested hashes and arrays. + # + # hash = { person: { name: 'Rob', age: '28' } } + # + # hash.deep_transform_keys{ |key| key.to_s.upcase } + # # => {"PERSON"=>{"NAME"=>"Rob", "AGE"=>"28"}} + def deep_transform_keys(&block) + _deep_transform_keys_in_object(self, &block) + end + + # Destructively converts all keys by using the block operation. + # This includes the keys from the root hash and from all + # nested hashes and arrays. + def deep_transform_keys!(&block) + _deep_transform_keys_in_object!(self, &block) + end + + # Returns a new hash with all keys converted to strings. + # This includes the keys from the root hash and from all + # nested hashes and arrays. + # + # hash = { person: { name: 'Rob', age: '28' } } + # + # hash.deep_stringify_keys + # # => {"person"=>{"name"=>"Rob", "age"=>"28"}} + def deep_stringify_keys + deep_transform_keys(&:to_s) + end + + # Destructively converts all keys to strings. + # This includes the keys from the root hash and from all + # nested hashes and arrays. + def deep_stringify_keys! + deep_transform_keys!(&:to_s) + end + + # Returns a new hash with all keys converted to symbols, as long as + # they respond to +to_sym+. This includes the keys from the root hash + # and from all nested hashes and arrays. + # + # hash = { 'person' => { 'name' => 'Rob', 'age' => '28' } } + # + # hash.deep_symbolize_keys + # # => {:person=>{:name=>"Rob", :age=>"28"}} + def deep_symbolize_keys + deep_transform_keys { |key| key.to_sym rescue key } + end + + # Destructively converts all keys to symbols, as long as they respond + # to +to_sym+. This includes the keys from the root hash and from all + # nested hashes and arrays. + def deep_symbolize_keys! + deep_transform_keys! { |key| key.to_sym rescue key } + end + + private + # support methods for deep transforming nested hashes and arrays + def _deep_transform_keys_in_object(object, &block) + case object + when Hash + object.each_with_object({}) do |(key, value), result| + result[yield(key)] = _deep_transform_keys_in_object(value, &block) + end + when Array + object.map { |e| _deep_transform_keys_in_object(e, &block) } + else + object + end + end + + def _deep_transform_keys_in_object!(object, &block) + case object + when Hash + object.keys.each do |key| + value = object.delete(key) + object[yield(key)] = _deep_transform_keys_in_object!(value, &block) + end + object + when Array + object.map! { |e| _deep_transform_keys_in_object!(e, &block) } + else + object + end + end +end diff --git a/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb b/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb new file mode 100644 index 0000000000..ef8d592829 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Hash + # Merges the caller into +other_hash+. For example, + # + # options = options.reverse_merge(size: 25, velocity: 10) + # + # is equivalent to + # + # options = { size: 25, velocity: 10 }.merge(options) + # + # This is particularly useful for initializing an options hash + # with default values. + def reverse_merge(other_hash) + other_hash.merge(self) + end + alias_method :with_defaults, :reverse_merge + + # Destructive +reverse_merge+. + def reverse_merge!(other_hash) + replace(reverse_merge(other_hash)) + end + alias_method :reverse_update, :reverse_merge! + alias_method :with_defaults!, :reverse_merge! +end diff --git a/activesupport/lib/active_support/core_ext/hash/slice.rb b/activesupport/lib/active_support/core_ext/hash/slice.rb new file mode 100644 index 0000000000..3d0f8a1e62 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/slice.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Hash + # Replaces the hash with only the given keys. + # Returns a hash containing the removed key/value pairs. + # + # hash = { a: 1, b: 2, c: 3, d: 4 } + # hash.slice!(:a, :b) # => {:c=>3, :d=>4} + # hash # => {:a=>1, :b=>2} + def slice!(*keys) + omit = slice(*self.keys - keys) + hash = slice(*keys) + hash.default = default + hash.default_proc = default_proc if default_proc + replace(hash) + omit + end + + # Removes and returns the key/value pairs matching the given keys. + # + # { a: 1, b: 2, c: 3, d: 4 }.extract!(:a, :b) # => {:a=>1, :b=>2} + # { a: 1, b: 2 }.extract!(:a, :x) # => {:a=>1} + def extract!(*keys) + keys.each_with_object(self.class.new) { |key, result| result[key] = delete(key) if has_key?(key) } + end +end diff --git a/activesupport/lib/active_support/core_ext/hash/transform_values.rb b/activesupport/lib/active_support/core_ext/hash/transform_values.rb new file mode 100644 index 0000000000..e4aeb0e891 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/hash/transform_values.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_support/deprecation" + +ActiveSupport::Deprecation.warn "Ruby 2.5+ (required by Rails 6) provides Hash#transform_values natively, so requiring active_support/core_ext/hash/transform_values is no longer necessary. Requiring it will raise LoadError in Rails 6.1." diff --git a/activesupport/lib/active_support/core_ext/integer.rb b/activesupport/lib/active_support/core_ext/integer.rb new file mode 100644 index 0000000000..d22701306a --- /dev/null +++ b/activesupport/lib/active_support/core_ext/integer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/multiple" +require "active_support/core_ext/integer/inflections" +require "active_support/core_ext/integer/time" diff --git a/activesupport/lib/active_support/core_ext/integer/inflections.rb b/activesupport/lib/active_support/core_ext/integer/inflections.rb new file mode 100644 index 0000000000..aef3266f28 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/integer/inflections.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "active_support/inflector" + +class Integer + # Ordinalize turns a number into an ordinal string used to denote the + # position in an ordered sequence such as 1st, 2nd, 3rd, 4th. + # + # 1.ordinalize # => "1st" + # 2.ordinalize # => "2nd" + # 1002.ordinalize # => "1002nd" + # 1003.ordinalize # => "1003rd" + # -11.ordinalize # => "-11th" + # -1001.ordinalize # => "-1001st" + def ordinalize + ActiveSupport::Inflector.ordinalize(self) + end + + # Ordinal returns the suffix used to denote the position + # in an ordered sequence such as 1st, 2nd, 3rd, 4th. + # + # 1.ordinal # => "st" + # 2.ordinal # => "nd" + # 1002.ordinal # => "nd" + # 1003.ordinal # => "rd" + # -11.ordinal # => "th" + # -1001.ordinal # => "st" + def ordinal + ActiveSupport::Inflector.ordinal(self) + end +end diff --git a/activesupport/lib/active_support/core_ext/integer/multiple.rb b/activesupport/lib/active_support/core_ext/integer/multiple.rb new file mode 100644 index 0000000000..bd57a909c5 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/integer/multiple.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Integer + # Check whether the integer is evenly divisible by the argument. + # + # 0.multiple_of?(0) # => true + # 6.multiple_of?(5) # => false + # 10.multiple_of?(2) # => true + def multiple_of?(number) + number == 0 ? self == 0 : self % number == 0 + end +end diff --git a/activesupport/lib/active_support/core_ext/integer/time.rb b/activesupport/lib/active_support/core_ext/integer/time.rb new file mode 100644 index 0000000000..5efb89cf9f --- /dev/null +++ b/activesupport/lib/active_support/core_ext/integer/time.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "active_support/duration" +require "active_support/core_ext/numeric/time" + +class Integer + # Returns a Duration instance matching the number of months provided. + # + # 2.months # => 2 months + def months + ActiveSupport::Duration.months(self) + end + alias :month :months + + # Returns a Duration instance matching the number of years provided. + # + # 2.years # => 2 years + def years + ActiveSupport::Duration.years(self) + end + alias :year :years +end diff --git a/activesupport/lib/active_support/core_ext/kernel.rb b/activesupport/lib/active_support/core_ext/kernel.rb new file mode 100644 index 0000000000..0f4356fbdd --- /dev/null +++ b/activesupport/lib/active_support/core_ext/kernel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "active_support/core_ext/kernel/agnostics" +require "active_support/core_ext/kernel/concern" +require "active_support/core_ext/kernel/reporting" +require "active_support/core_ext/kernel/singleton_class" diff --git a/activesupport/lib/active_support/core_ext/kernel/agnostics.rb b/activesupport/lib/active_support/core_ext/kernel/agnostics.rb new file mode 100644 index 0000000000..403b5f31f0 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/kernel/agnostics.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Object + # Makes backticks behave (somewhat more) similarly on all platforms. + # On win32 `nonexistent_command` raises Errno::ENOENT; on Unix, the + # spawned shell prints a message to stderr and sets $?. We emulate + # Unix on the former but not the latter. + def `(command) #:nodoc: + super + rescue Errno::ENOENT => e + STDERR.puts "#$0: #{e}" + end +end diff --git a/activesupport/lib/active_support/core_ext/kernel/concern.rb b/activesupport/lib/active_support/core_ext/kernel/concern.rb new file mode 100644 index 0000000000..0b2baed780 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/kernel/concern.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/concerning" + +module Kernel + module_function + + # A shortcut to define a toplevel concern, not within a module. + # + # See Module::Concerning for more. + def concern(topic, &module_definition) + Object.concern topic, &module_definition + end +end diff --git a/activesupport/lib/active_support/core_ext/kernel/reporting.rb b/activesupport/lib/active_support/core_ext/kernel/reporting.rb new file mode 100644 index 0000000000..9155bd6c10 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/kernel/reporting.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Kernel + module_function + + # Sets $VERBOSE to +nil+ for the duration of the block and back to its original + # value afterwards. + # + # silence_warnings do + # value = noisy_call # no warning voiced + # end + # + # noisy_call # warning voiced + def silence_warnings + with_warnings(nil) { yield } + end + + # Sets $VERBOSE to +true+ for the duration of the block and back to its + # original value afterwards. + def enable_warnings + with_warnings(true) { yield } + end + + # Sets $VERBOSE for the duration of the block and back to its original + # value afterwards. + def with_warnings(flag) + old_verbose, $VERBOSE = $VERBOSE, flag + yield + ensure + $VERBOSE = old_verbose + end + + # Blocks and ignores any exception passed as argument if raised within the block. + # + # suppress(ZeroDivisionError) do + # 1/0 + # puts 'This code is NOT reached' + # end + # + # puts 'This code gets executed and nothing related to ZeroDivisionError was seen' + def suppress(*exception_classes) + yield + rescue *exception_classes + end +end diff --git a/activesupport/lib/active_support/core_ext/kernel/singleton_class.rb b/activesupport/lib/active_support/core_ext/kernel/singleton_class.rb new file mode 100644 index 0000000000..6715eba80a --- /dev/null +++ b/activesupport/lib/active_support/core_ext/kernel/singleton_class.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Kernel + # class_eval on an object acts like singleton_class.class_eval. + def class_eval(*args, &block) + singleton_class.class_eval(*args, &block) + end +end diff --git a/activesupport/lib/active_support/core_ext/load_error.rb b/activesupport/lib/active_support/core_ext/load_error.rb new file mode 100644 index 0000000000..b81ed0605e --- /dev/null +++ b/activesupport/lib/active_support/core_ext/load_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class LoadError + # Returns true if the given path name (except perhaps for the ".rb" + # extension) is the missing file which caused the exception to be raised. + def is_missing?(location) + location.sub(/\.rb$/, "") == path.to_s.sub(/\.rb$/, "") + end +end diff --git a/activesupport/lib/active_support/core_ext/marshal.rb b/activesupport/lib/active_support/core_ext/marshal.rb new file mode 100644 index 0000000000..0c72cd7b47 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/marshal.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActiveSupport + module MarshalWithAutoloading # :nodoc: + def load(source, proc = nil) + super(source, proc) + rescue ArgumentError, NameError => exc + if exc.message.match(%r|undefined class/module (.+?)(?:::)?\z|) + # try loading the class/module + loaded = $1.constantize + + raise unless $1 == loaded.name + + # if it is an IO we need to go back to read the object + source.rewind if source.respond_to?(:rewind) + retry + else + raise exc + end + end + end +end + +Marshal.singleton_class.prepend(ActiveSupport::MarshalWithAutoloading) diff --git a/activesupport/lib/active_support/core_ext/module.rb b/activesupport/lib/active_support/core_ext/module.rb new file mode 100644 index 0000000000..d91e3fba6a --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/aliasing" +require "active_support/core_ext/module/introspection" +require "active_support/core_ext/module/anonymous" +require "active_support/core_ext/module/reachable" +require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/module/attribute_accessors_per_thread" +require "active_support/core_ext/module/attr_internal" +require "active_support/core_ext/module/concerning" +require "active_support/core_ext/module/delegation" +require "active_support/core_ext/module/deprecation" +require "active_support/core_ext/module/redefine_method" +require "active_support/core_ext/module/remove_method" diff --git a/activesupport/lib/active_support/core_ext/module/aliasing.rb b/activesupport/lib/active_support/core_ext/module/aliasing.rb new file mode 100644 index 0000000000..6f64d11627 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/aliasing.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Module + # Allows you to make aliases for attributes, which includes + # getter, setter, and a predicate. + # + # class Content < ActiveRecord::Base + # # has a title attribute + # end + # + # class Email < Content + # alias_attribute :subject, :title + # end + # + # e = Email.find(1) + # e.title # => "Superstars" + # e.subject # => "Superstars" + # e.subject? # => true + # e.subject = "Megastars" + # e.title # => "Megastars" + def alias_attribute(new_name, old_name) + # The following reader methods use an explicit `self` receiver in order to + # support aliases that start with an uppercase letter. Otherwise, they would + # be resolved as constants instead. + module_eval <<-STR, __FILE__, __LINE__ + 1 + def #{new_name}; self.#{old_name}; end # def subject; self.title; end + def #{new_name}?; self.#{old_name}?; end # def subject?; self.title?; end + def #{new_name}=(v); self.#{old_name} = v; end # def subject=(v); self.title = v; end + STR + end +end diff --git a/activesupport/lib/active_support/core_ext/module/anonymous.rb b/activesupport/lib/active_support/core_ext/module/anonymous.rb new file mode 100644 index 0000000000..d1c86b8722 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/anonymous.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Module + # A module may or may not have a name. + # + # module M; end + # M.name # => "M" + # + # m = Module.new + # m.name # => nil + # + # +anonymous?+ method returns true if module does not have a name, false otherwise: + # + # Module.new.anonymous? # => true + # + # module M; end + # M.anonymous? # => false + # + # A module gets a name when it is first assigned to a constant. Either + # via the +module+ or +class+ keyword or by an explicit assignment: + # + # m = Module.new # creates an anonymous module + # m.anonymous? # => true + # M = m # m gets a name here as a side-effect + # m.name # => "M" + # m.anonymous? # => false + def anonymous? + name.nil? + end +end diff --git a/activesupport/lib/active_support/core_ext/module/attr_internal.rb b/activesupport/lib/active_support/core_ext/module/attr_internal.rb new file mode 100644 index 0000000000..7801f6d181 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/attr_internal.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Module + # Declares an attribute reader backed by an internally-named instance variable. + def attr_internal_reader(*attrs) + attrs.each { |attr_name| attr_internal_define(attr_name, :reader) } + end + + # Declares an attribute writer backed by an internally-named instance variable. + def attr_internal_writer(*attrs) + attrs.each { |attr_name| attr_internal_define(attr_name, :writer) } + end + + # Declares an attribute reader and writer backed by an internally-named instance + # variable. + def attr_internal_accessor(*attrs) + attr_internal_reader(*attrs) + attr_internal_writer(*attrs) + end + alias_method :attr_internal, :attr_internal_accessor + + class << self; attr_accessor :attr_internal_naming_format end + self.attr_internal_naming_format = "@_%s" + + private + def attr_internal_ivar_name(attr) + Module.attr_internal_naming_format % attr + end + + def attr_internal_define(attr_name, type) + internal_name = attr_internal_ivar_name(attr_name).sub(/\A@/, "") + # use native attr_* methods as they are faster on some Ruby implementations + send("attr_#{type}", internal_name) + attr_name, internal_name = "#{attr_name}=", "#{internal_name}=" if type == :writer + alias_method attr_name, internal_name + remove_method internal_name + end +end diff --git a/activesupport/lib/active_support/core_ext/module/attribute_accessors.rb b/activesupport/lib/active_support/core_ext/module/attribute_accessors.rb new file mode 100644 index 0000000000..5850e0193f --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/attribute_accessors.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +# Extends the module object with class/module and instance accessors for +# class/module attributes, just like the native attr* accessors for instance +# attributes. +class Module + # Defines a class attribute and creates a class and instance reader methods. + # The underlying class variable is set to +nil+, if it is not previously + # defined. All class and instance methods created will be public, even if + # this method is called with a private or protected access modifier. + # + # module HairColors + # mattr_reader :hair_colors + # end + # + # HairColors.hair_colors # => nil + # HairColors.class_variable_set("@@hair_colors", [:brown, :black]) + # HairColors.hair_colors # => [:brown, :black] + # + # The attribute name must be a valid method name in Ruby. + # + # module Foo + # mattr_reader :"1_Badname" + # end + # # => NameError: invalid attribute name: 1_Badname + # + # If you want to opt out the creation on the instance reader method, pass + # <tt>instance_reader: false</tt> or <tt>instance_accessor: false</tt>. + # + # module HairColors + # mattr_reader :hair_colors, instance_reader: false + # end + # + # class Person + # include HairColors + # end + # + # Person.new.hair_colors # => NoMethodError + # + # You can set a default value for the attribute. + # + # module HairColors + # mattr_reader :hair_colors, default: [:brown, :black, :blonde, :red] + # end + # + # class Person + # include HairColors + # end + # + # Person.new.hair_colors # => [:brown, :black, :blonde, :red] + def mattr_reader(*syms, instance_reader: true, instance_accessor: true, default: nil) + syms.each do |sym| + raise NameError.new("invalid attribute name: #{sym}") unless /\A[_A-Za-z]\w*\z/.match?(sym) + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + @@#{sym} = nil unless defined? @@#{sym} + + def self.#{sym} + @@#{sym} + end + EOS + + if instance_reader && instance_accessor + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def #{sym} + @@#{sym} + end + EOS + end + + sym_default_value = (block_given? && default.nil?) ? yield : default + class_variable_set("@@#{sym}", sym_default_value) unless sym_default_value.nil? + end + end + alias :cattr_reader :mattr_reader + + # Defines a class attribute and creates a class and instance writer methods to + # allow assignment to the attribute. All class and instance methods created + # will be public, even if this method is called with a private or protected + # access modifier. + # + # module HairColors + # mattr_writer :hair_colors + # end + # + # class Person + # include HairColors + # end + # + # HairColors.hair_colors = [:brown, :black] + # Person.class_variable_get("@@hair_colors") # => [:brown, :black] + # Person.new.hair_colors = [:blonde, :red] + # HairColors.class_variable_get("@@hair_colors") # => [:blonde, :red] + # + # If you want to opt out the instance writer method, pass + # <tt>instance_writer: false</tt> or <tt>instance_accessor: false</tt>. + # + # module HairColors + # mattr_writer :hair_colors, instance_writer: false + # end + # + # class Person + # include HairColors + # end + # + # Person.new.hair_colors = [:blonde, :red] # => NoMethodError + # + # You can set a default value for the attribute. + # + # module HairColors + # mattr_writer :hair_colors, default: [:brown, :black, :blonde, :red] + # end + # + # class Person + # include HairColors + # end + # + # Person.class_variable_get("@@hair_colors") # => [:brown, :black, :blonde, :red] + def mattr_writer(*syms, instance_writer: true, instance_accessor: true, default: nil) + syms.each do |sym| + raise NameError.new("invalid attribute name: #{sym}") unless /\A[_A-Za-z]\w*\z/.match?(sym) + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + @@#{sym} = nil unless defined? @@#{sym} + + def self.#{sym}=(obj) + @@#{sym} = obj + end + EOS + + if instance_writer && instance_accessor + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def #{sym}=(obj) + @@#{sym} = obj + end + EOS + end + + sym_default_value = (block_given? && default.nil?) ? yield : default + send("#{sym}=", sym_default_value) unless sym_default_value.nil? + end + end + alias :cattr_writer :mattr_writer + + # Defines both class and instance accessors for class attributes. + # All class and instance methods created will be public, even if + # this method is called with a private or protected access modifier. + # + # module HairColors + # mattr_accessor :hair_colors + # end + # + # class Person + # include HairColors + # end + # + # HairColors.hair_colors = [:brown, :black, :blonde, :red] + # HairColors.hair_colors # => [:brown, :black, :blonde, :red] + # Person.new.hair_colors # => [:brown, :black, :blonde, :red] + # + # If a subclass changes the value then that would also change the value for + # parent class. Similarly if parent class changes the value then that would + # change the value of subclasses too. + # + # class Citizen < Person + # end + # + # Citizen.new.hair_colors << :blue + # Person.new.hair_colors # => [:brown, :black, :blonde, :red, :blue] + # + # To opt out of the instance writer method, pass <tt>instance_writer: false</tt>. + # To opt out of the instance reader method, pass <tt>instance_reader: false</tt>. + # + # module HairColors + # mattr_accessor :hair_colors, instance_writer: false, instance_reader: false + # end + # + # class Person + # include HairColors + # end + # + # Person.new.hair_colors = [:brown] # => NoMethodError + # Person.new.hair_colors # => NoMethodError + # + # Or pass <tt>instance_accessor: false</tt>, to opt out both instance methods. + # + # module HairColors + # mattr_accessor :hair_colors, instance_accessor: false + # end + # + # class Person + # include HairColors + # end + # + # Person.new.hair_colors = [:brown] # => NoMethodError + # Person.new.hair_colors # => NoMethodError + # + # You can set a default value for the attribute. + # + # module HairColors + # mattr_accessor :hair_colors, default: [:brown, :black, :blonde, :red] + # end + # + # class Person + # include HairColors + # end + # + # Person.class_variable_get("@@hair_colors") # => [:brown, :black, :blonde, :red] + def mattr_accessor(*syms, instance_reader: true, instance_writer: true, instance_accessor: true, default: nil, &blk) + mattr_reader(*syms, instance_reader: instance_reader, instance_accessor: instance_accessor, default: default, &blk) + mattr_writer(*syms, instance_writer: instance_writer, instance_accessor: instance_accessor, default: default) + end + alias :cattr_accessor :mattr_accessor +end diff --git a/activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb b/activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb new file mode 100644 index 0000000000..cb996e9e6d --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +# Extends the module object with class/module and instance accessors for +# class/module attributes, just like the native attr* accessors for instance +# attributes, but does so on a per-thread basis. +# +# So the values are scoped within the Thread.current space under the class name +# of the module. +class Module + # Defines a per-thread class attribute and creates class and instance reader methods. + # The underlying per-thread class variable is set to +nil+, if it is not previously defined. + # + # module Current + # thread_mattr_reader :user + # end + # + # Current.user # => nil + # Thread.current[:attr_Current_user] = "DHH" + # Current.user # => "DHH" + # + # The attribute name must be a valid method name in Ruby. + # + # module Foo + # thread_mattr_reader :"1_Badname" + # end + # # => NameError: invalid attribute name: 1_Badname + # + # If you want to opt out of the creation of the instance reader method, pass + # <tt>instance_reader: false</tt> or <tt>instance_accessor: false</tt>. + # + # class Current + # thread_mattr_reader :user, instance_reader: false + # end + # + # Current.new.user # => NoMethodError + def thread_mattr_reader(*syms, instance_reader: true, instance_accessor: true) # :nodoc: + syms.each do |sym| + raise NameError.new("invalid attribute name: #{sym}") unless /^[_A-Za-z]\w*$/.match?(sym) + + # The following generated method concatenates `name` because we want it + # to work with inheritance via polymorphism. + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def self.#{sym} + Thread.current["attr_" + name + "_#{sym}"] + end + EOS + + if instance_reader && instance_accessor + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def #{sym} + self.class.#{sym} + end + EOS + end + end + end + alias :thread_cattr_reader :thread_mattr_reader + + # Defines a per-thread class attribute and creates a class and instance writer methods to + # allow assignment to the attribute. + # + # module Current + # thread_mattr_writer :user + # end + # + # Current.user = "DHH" + # Thread.current[:attr_Current_user] # => "DHH" + # + # If you want to opt out of the creation of the instance writer method, pass + # <tt>instance_writer: false</tt> or <tt>instance_accessor: false</tt>. + # + # class Current + # thread_mattr_writer :user, instance_writer: false + # end + # + # Current.new.user = "DHH" # => NoMethodError + def thread_mattr_writer(*syms, instance_writer: true, instance_accessor: true) # :nodoc: + syms.each do |sym| + raise NameError.new("invalid attribute name: #{sym}") unless /^[_A-Za-z]\w*$/.match?(sym) + + # The following generated method concatenates `name` because we want it + # to work with inheritance via polymorphism. + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def self.#{sym}=(obj) + Thread.current["attr_" + name + "_#{sym}"] = obj + end + EOS + + if instance_writer && instance_accessor + class_eval(<<-EOS, __FILE__, __LINE__ + 1) + def #{sym}=(obj) + self.class.#{sym} = obj + end + EOS + end + end + end + alias :thread_cattr_writer :thread_mattr_writer + + # Defines both class and instance accessors for class attributes. + # + # class Account + # thread_mattr_accessor :user + # end + # + # Account.user = "DHH" + # Account.user # => "DHH" + # Account.new.user # => "DHH" + # + # If a subclass changes the value, the parent class' value is not changed. + # Similarly, if the parent class changes the value, the value of subclasses + # is not changed. + # + # class Customer < Account + # end + # + # Customer.user = "Rafael" + # Customer.user # => "Rafael" + # Account.user # => "DHH" + # + # To opt out of the instance writer method, pass <tt>instance_writer: false</tt>. + # To opt out of the instance reader method, pass <tt>instance_reader: false</tt>. + # + # class Current + # thread_mattr_accessor :user, instance_writer: false, instance_reader: false + # end + # + # Current.new.user = "DHH" # => NoMethodError + # Current.new.user # => NoMethodError + # + # Or pass <tt>instance_accessor: false</tt>, to opt out both instance methods. + # + # class Current + # thread_mattr_accessor :user, instance_accessor: false + # end + # + # Current.new.user = "DHH" # => NoMethodError + # Current.new.user # => NoMethodError + def thread_mattr_accessor(*syms, instance_reader: true, instance_writer: true, instance_accessor: true) + thread_mattr_reader(*syms, instance_reader: instance_reader, instance_accessor: instance_accessor) + thread_mattr_writer(*syms, instance_writer: instance_writer, instance_accessor: instance_accessor) + end + alias :thread_cattr_accessor :thread_mattr_accessor +end diff --git a/activesupport/lib/active_support/core_ext/module/concerning.rb b/activesupport/lib/active_support/core_ext/module/concerning.rb new file mode 100644 index 0000000000..7bbbf321ab --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/concerning.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "active_support/concern" + +class Module + # = Bite-sized separation of concerns + # + # We often find ourselves with a medium-sized chunk of behavior that we'd + # like to extract, but only mix in to a single class. + # + # Extracting a plain old Ruby object to encapsulate it and collaborate or + # delegate to the original object is often a good choice, but when there's + # no additional state to encapsulate or we're making DSL-style declarations + # about the parent class, introducing new collaborators can obfuscate rather + # than simplify. + # + # The typical route is to just dump everything in a monolithic class, perhaps + # with a comment, as a least-bad alternative. Using modules in separate files + # means tedious sifting to get a big-picture view. + # + # = Dissatisfying ways to separate small concerns + # + # == Using comments: + # + # class Todo < ApplicationRecord + # # Other todo implementation + # # ... + # + # ## Event tracking + # has_many :events + # + # before_create :track_creation + # + # private + # def track_creation + # # ... + # end + # end + # + # == With an inline module: + # + # Noisy syntax. + # + # class Todo < ApplicationRecord + # # Other todo implementation + # # ... + # + # module EventTracking + # extend ActiveSupport::Concern + # + # included do + # has_many :events + # before_create :track_creation + # end + # + # private + # def track_creation + # # ... + # end + # end + # include EventTracking + # end + # + # == Mix-in noise exiled to its own file: + # + # Once our chunk of behavior starts pushing the scroll-to-understand-it + # boundary, we give in and move it to a separate file. At this size, the + # increased overhead can be a reasonable tradeoff even if it reduces our + # at-a-glance perception of how things work. + # + # class Todo < ApplicationRecord + # # Other todo implementation + # # ... + # + # include TodoEventTracking + # end + # + # = Introducing Module#concerning + # + # By quieting the mix-in noise, we arrive at a natural, low-ceremony way to + # separate bite-sized concerns. + # + # class Todo < ApplicationRecord + # # Other todo implementation + # # ... + # + # concerning :EventTracking do + # included do + # has_many :events + # before_create :track_creation + # end + # + # private + # def track_creation + # # ... + # end + # end + # end + # + # Todo.ancestors + # # => [Todo, Todo::EventTracking, ApplicationRecord, Object] + # + # This small step has some wonderful ripple effects. We can + # * grok the behavior of our class in one glance, + # * clean up monolithic junk-drawer classes by separating their concerns, and + # * stop leaning on protected/private for crude "this is internal stuff" modularity. + module Concerning + # Define a new concern and mix it in. + def concerning(topic, &block) + include concern(topic, &block) + end + + # A low-cruft shortcut to define a concern. + # + # concern :EventTracking do + # ... + # end + # + # is equivalent to + # + # module EventTracking + # extend ActiveSupport::Concern + # + # ... + # end + def concern(topic, &module_definition) + const_set topic, Module.new { + extend ::ActiveSupport::Concern + module_eval(&module_definition) + } + end + end + include Concerning +end diff --git a/activesupport/lib/active_support/core_ext/module/delegation.rb b/activesupport/lib/active_support/core_ext/module/delegation.rb new file mode 100644 index 0000000000..be90390ae4 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/delegation.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require "set" + +class Module + # Error generated by +delegate+ when a method is called on +nil+ and +allow_nil+ + # option is not used. + class DelegationError < NoMethodError; end + + RUBY_RESERVED_KEYWORDS = %w(alias and BEGIN begin break case class def defined? do + else elsif END end ensure false for if in module next nil not or redo rescue retry + return self super then true undef unless until when while yield) + DELEGATION_RESERVED_KEYWORDS = %w(_ arg args block) + DELEGATION_RESERVED_METHOD_NAMES = Set.new( + RUBY_RESERVED_KEYWORDS + DELEGATION_RESERVED_KEYWORDS + ).freeze + + # Provides a +delegate+ class method to easily expose contained objects' + # public methods as your own. + # + # ==== Options + # * <tt>:to</tt> - Specifies the target object name as a symbol or string + # * <tt>:prefix</tt> - Prefixes the new method with the target name or a custom prefix + # * <tt>:allow_nil</tt> - If set to true, prevents a +Module::DelegationError+ + # from being raised + # * <tt>:private</tt> - If set to true, changes method visibility to private + # + # The macro receives one or more method names (specified as symbols or + # strings) and the name of the target object via the <tt>:to</tt> option + # (also a symbol or string). + # + # Delegation is particularly useful with Active Record associations: + # + # class Greeter < ActiveRecord::Base + # def hello + # 'hello' + # end + # + # def goodbye + # 'goodbye' + # end + # end + # + # class Foo < ActiveRecord::Base + # belongs_to :greeter + # delegate :hello, to: :greeter + # end + # + # Foo.new.hello # => "hello" + # Foo.new.goodbye # => NoMethodError: undefined method `goodbye' for #<Foo:0x1af30c> + # + # Multiple delegates to the same target are allowed: + # + # class Foo < ActiveRecord::Base + # belongs_to :greeter + # delegate :hello, :goodbye, to: :greeter + # end + # + # Foo.new.goodbye # => "goodbye" + # + # Methods can be delegated to instance variables, class variables, or constants + # by providing them as a symbols: + # + # class Foo + # CONSTANT_ARRAY = [0,1,2,3] + # @@class_array = [4,5,6,7] + # + # def initialize + # @instance_array = [8,9,10,11] + # end + # delegate :sum, to: :CONSTANT_ARRAY + # delegate :min, to: :@@class_array + # delegate :max, to: :@instance_array + # end + # + # Foo.new.sum # => 6 + # Foo.new.min # => 4 + # Foo.new.max # => 11 + # + # It's also possible to delegate a method to the class by using +:class+: + # + # class Foo + # def self.hello + # "world" + # end + # + # delegate :hello, to: :class + # end + # + # Foo.new.hello # => "world" + # + # Delegates can optionally be prefixed using the <tt>:prefix</tt> option. If the value + # is <tt>true</tt>, the delegate methods are prefixed with the name of the object being + # delegated to. + # + # Person = Struct.new(:name, :address) + # + # class Invoice < Struct.new(:client) + # delegate :name, :address, to: :client, prefix: true + # end + # + # john_doe = Person.new('John Doe', 'Vimmersvej 13') + # invoice = Invoice.new(john_doe) + # invoice.client_name # => "John Doe" + # invoice.client_address # => "Vimmersvej 13" + # + # It is also possible to supply a custom prefix. + # + # class Invoice < Struct.new(:client) + # delegate :name, :address, to: :client, prefix: :customer + # end + # + # invoice = Invoice.new(john_doe) + # invoice.customer_name # => 'John Doe' + # invoice.customer_address # => 'Vimmersvej 13' + # + # The delegated methods are public by default. + # Pass <tt>private: true</tt> to change that. + # + # class User < ActiveRecord::Base + # has_one :profile + # delegate :first_name, to: :profile + # delegate :date_of_birth, to: :profile, private: true + # + # def age + # Date.today.year - date_of_birth.year + # end + # end + # + # User.new.first_name # => "Tomas" + # User.new.date_of_birth # => NoMethodError: private method `date_of_birth' called for #<User:0x00000008221340> + # User.new.age # => 2 + # + # If the target is +nil+ and does not respond to the delegated method a + # +Module::DelegationError+ is raised. If you wish to instead return +nil+, + # use the <tt>:allow_nil</tt> option. + # + # class User < ActiveRecord::Base + # has_one :profile + # delegate :age, to: :profile + # end + # + # User.new.age + # # => Module::DelegationError: User#age delegated to profile.age, but profile is nil + # + # But if not having a profile yet is fine and should not be an error + # condition: + # + # class User < ActiveRecord::Base + # has_one :profile + # delegate :age, to: :profile, allow_nil: true + # end + # + # User.new.age # nil + # + # Note that if the target is not +nil+ then the call is attempted regardless of the + # <tt>:allow_nil</tt> option, and thus an exception is still raised if said object + # does not respond to the method: + # + # class Foo + # def initialize(bar) + # @bar = bar + # end + # + # delegate :name, to: :@bar, allow_nil: true + # end + # + # Foo.new("Bar").name # raises NoMethodError: undefined method `name' + # + # The target method must be public, otherwise it will raise +NoMethodError+. + def delegate(*methods, to: nil, prefix: nil, allow_nil: nil, private: nil) + unless to + raise ArgumentError, "Delegation needs a target. Supply an options hash with a :to key as the last argument (e.g. delegate :hello, to: :greeter)." + end + + if prefix == true && /^[^a-z_]/.match?(to) + raise ArgumentError, "Can only automatically set the delegation prefix when delegating to a method." + end + + method_prefix = \ + if prefix + "#{prefix == true ? to : prefix}_" + else + "" + end + + location = caller_locations(1, 1).first + file, line = location.path, location.lineno + + to = to.to_s + to = "self.#{to}" if DELEGATION_RESERVED_METHOD_NAMES.include?(to) + + method_names = methods.map do |method| + # Attribute writer methods only accept one argument. Makes sure []= + # methods still accept two arguments. + definition = /[^\]]=$/.match?(method) ? "arg" : "*args, &block" + + # The following generated method calls the target exactly once, storing + # the returned value in a dummy variable. + # + # Reason is twofold: On one hand doing less calls is in general better. + # On the other hand it could be that the target has side-effects, + # whereas conceptually, from the user point of view, the delegator should + # be doing one call. + if allow_nil + method_def = [ + "def #{method_prefix}#{method}(#{definition})", + "_ = #{to}", + "if !_.nil? || nil.respond_to?(:#{method})", + " _.#{method}(#{definition})", + "end", + "end" + ].join ";" + else + exception = %(raise DelegationError, "#{self}##{method_prefix}#{method} delegated to #{to}.#{method}, but #{to} is nil: \#{self.inspect}") + + method_def = [ + "def #{method_prefix}#{method}(#{definition})", + " _ = #{to}", + " _.#{method}(#{definition})", + "rescue NoMethodError => e", + " if _.nil? && e.name == :#{method}", + " #{exception}", + " else", + " raise", + " end", + "end" + ].join ";" + end + + module_eval(method_def, file, line) + end + + private(*method_names) if private + method_names + end + + # When building decorators, a common pattern may emerge: + # + # class Partition + # def initialize(event) + # @event = event + # end + # + # def person + # @event.detail.person || @event.creator + # end + # + # private + # def respond_to_missing?(name, include_private = false) + # @event.respond_to?(name, include_private) + # end + # + # def method_missing(method, *args, &block) + # @event.send(method, *args, &block) + # end + # end + # + # With <tt>Module#delegate_missing_to</tt>, the above is condensed to: + # + # class Partition + # delegate_missing_to :@event + # + # def initialize(event) + # @event = event + # end + # + # def person + # @event.detail.person || @event.creator + # end + # end + # + # The target can be anything callable within the object, e.g. instance + # variables, methods, constants, etc. + # + # The delegated method must be public on the target, otherwise it will + # raise +NoMethodError+. + def delegate_missing_to(target) + target = target.to_s + target = "self.#{target}" if DELEGATION_RESERVED_METHOD_NAMES.include?(target) + + module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def respond_to_missing?(name, include_private = false) + # It may look like an oversight, but we deliberately do not pass + # +include_private+, because they do not get delegated. + + #{target}.respond_to?(name) || super + end + + def method_missing(method, *args, &block) + if #{target}.respond_to?(method) + #{target}.public_send(method, *args, &block) + else + begin + super + rescue NoMethodError + if #{target}.nil? + raise DelegationError, "\#{method} delegated to #{target}, but #{target} is nil" + else + raise + end + end + end + end + RUBY + end +end diff --git a/activesupport/lib/active_support/core_ext/module/deprecation.rb b/activesupport/lib/active_support/core_ext/module/deprecation.rb new file mode 100644 index 0000000000..71c42eb357 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/deprecation.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Module + # deprecate :foo + # deprecate bar: 'message' + # deprecate :foo, :bar, baz: 'warning!', qux: 'gone!' + # + # You can also use custom deprecator instance: + # + # deprecate :foo, deprecator: MyLib::Deprecator.new + # deprecate :foo, bar: "warning!", deprecator: MyLib::Deprecator.new + # + # \Custom deprecators must respond to <tt>deprecation_warning(deprecated_method_name, message, caller_backtrace)</tt> + # method where you can implement your custom warning behavior. + # + # class MyLib::Deprecator + # def deprecation_warning(deprecated_method_name, message, caller_backtrace = nil) + # message = "#{deprecated_method_name} is deprecated and will be removed from MyLibrary | #{message}" + # Kernel.warn message + # end + # end + def deprecate(*method_names) + ActiveSupport::Deprecation.deprecate_methods(self, *method_names) + end +end diff --git a/activesupport/lib/active_support/core_ext/module/introspection.rb b/activesupport/lib/active_support/core_ext/module/introspection.rb new file mode 100644 index 0000000000..9b6df40596 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/introspection.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "active_support/inflector" + +class Module + # Returns the name of the module containing this one. + # + # M::N.module_parent_name # => "M" + def module_parent_name + if defined?(@parent_name) + @parent_name + else + parent_name = name =~ /::[^:]+\Z/ ? $`.freeze : nil + @parent_name = parent_name unless frozen? + parent_name + end + end + + def parent_name + ActiveSupport::Deprecation.warn(<<-MSG.squish) + `Module#parent_name` has been renamed to `module_parent_name`. + `parent_name` is deprecated and will be removed in Rails 6.1. + MSG + module_parent_name + end + + # Returns the module which contains this one according to its name. + # + # module M + # module N + # end + # end + # X = M::N + # + # M::N.module_parent # => M + # X.module_parent # => M + # + # The parent of top-level and anonymous modules is Object. + # + # M.module_parent # => Object + # Module.new.module_parent # => Object + def module_parent + module_parent_name ? ActiveSupport::Inflector.constantize(module_parent_name) : Object + end + + def parent + ActiveSupport::Deprecation.warn(<<-MSG.squish) + `Module#parent` has been renamed to `module_parent`. + `parent` is deprecated and will be removed in Rails 6.1. + MSG + module_parent + end + + # Returns all the parents of this module according to its name, ordered from + # nested outwards. The receiver is not contained within the result. + # + # module M + # module N + # end + # end + # X = M::N + # + # M.module_parents # => [Object] + # M::N.module_parents # => [M, Object] + # X.module_parents # => [M, Object] + def module_parents + parents = [] + if module_parent_name + parts = module_parent_name.split("::") + until parts.empty? + parents << ActiveSupport::Inflector.constantize(parts * "::") + parts.pop + end + end + parents << Object unless parents.include? Object + parents + end + + def parents + ActiveSupport::Deprecation.warn(<<-MSG.squish) + `Module#parents` has been renamed to `module_parents`. + `parents` is deprecated and will be removed in Rails 6.1. + MSG + module_parents + end +end diff --git a/activesupport/lib/active_support/core_ext/module/reachable.rb b/activesupport/lib/active_support/core_ext/module/reachable.rb new file mode 100644 index 0000000000..e9cbda5245 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/reachable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/anonymous" +require "active_support/core_ext/string/inflections" + +class Module + def reachable? #:nodoc: + !anonymous? && name.safe_constantize.equal?(self) + end + deprecate :reachable? +end diff --git a/activesupport/lib/active_support/core_ext/module/redefine_method.rb b/activesupport/lib/active_support/core_ext/module/redefine_method.rb new file mode 100644 index 0000000000..5bd8e6e973 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/redefine_method.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Module + # Marks the named method as intended to be redefined, if it exists. + # Suppresses the Ruby method redefinition warning. Prefer + # #redefine_method where possible. + def silence_redefinition_of_method(method) + if method_defined?(method) || private_method_defined?(method) + # This suppresses the "method redefined" warning; the self-alias + # looks odd, but means we don't need to generate a unique name + alias_method method, method + end + end + + # Replaces the existing method definition, if there is one, with the passed + # block as its body. + def redefine_method(method, &block) + visibility = method_visibility(method) + silence_redefinition_of_method(method) + define_method(method, &block) + send(visibility, method) + end + + # Replaces the existing singleton method definition, if there is one, with + # the passed block as its body. + def redefine_singleton_method(method, &block) + singleton_class.redefine_method(method, &block) + end + + def method_visibility(method) # :nodoc: + case + when private_method_defined?(method) + :private + when protected_method_defined?(method) + :protected + else + :public + end + end +end diff --git a/activesupport/lib/active_support/core_ext/module/remove_method.rb b/activesupport/lib/active_support/core_ext/module/remove_method.rb new file mode 100644 index 0000000000..97eb5f9eca --- /dev/null +++ b/activesupport/lib/active_support/core_ext/module/remove_method.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/redefine_method" + +class Module + # Removes the named method, if it exists. + def remove_possible_method(method) + if method_defined?(method) || private_method_defined?(method) + undef_method(method) + end + end + + # Removes the named singleton method, if it exists. + def remove_possible_singleton_method(method) + singleton_class.remove_possible_method(method) + end +end diff --git a/activesupport/lib/active_support/core_ext/name_error.rb b/activesupport/lib/active_support/core_ext/name_error.rb new file mode 100644 index 0000000000..6d37cd9dfd --- /dev/null +++ b/activesupport/lib/active_support/core_ext/name_error.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class NameError + # Extract the name of the missing constant from the exception message. + # + # begin + # HelloWorld + # rescue NameError => e + # e.missing_name + # end + # # => "HelloWorld" + def missing_name + # Since ruby v2.3.0 `did_you_mean` gem is loaded by default. + # It extends NameError#message with spell corrections which are SLOW. + # We should use original_message message instead. + message = respond_to?(:original_message) ? original_message : self.message + + if /undefined local variable or method/ !~ message + $1 if /((::)?([A-Z]\w*)(::[A-Z]\w*)*)$/ =~ message + end + end + + # Was this exception raised because the given name was missing? + # + # begin + # HelloWorld + # rescue NameError => e + # e.missing_name?("HelloWorld") + # end + # # => true + def missing_name?(name) + if name.is_a? Symbol + self.name == name + else + missing_name == name.to_s + end + end +end diff --git a/activesupport/lib/active_support/core_ext/numeric.rb b/activesupport/lib/active_support/core_ext/numeric.rb new file mode 100644 index 0000000000..fe778470f1 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/numeric.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_support/core_ext/numeric/bytes" +require "active_support/core_ext/numeric/time" +require "active_support/core_ext/numeric/conversions" diff --git a/activesupport/lib/active_support/core_ext/numeric/bytes.rb b/activesupport/lib/active_support/core_ext/numeric/bytes.rb new file mode 100644 index 0000000000..b002eba406 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/numeric/bytes.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Numeric + KILOBYTE = 1024 + MEGABYTE = KILOBYTE * 1024 + GIGABYTE = MEGABYTE * 1024 + TERABYTE = GIGABYTE * 1024 + PETABYTE = TERABYTE * 1024 + EXABYTE = PETABYTE * 1024 + + # Enables the use of byte calculations and declarations, like 45.bytes + 2.6.megabytes + # + # 2.bytes # => 2 + def bytes + self + end + alias :byte :bytes + + # Returns the number of bytes equivalent to the kilobytes provided. + # + # 2.kilobytes # => 2048 + def kilobytes + self * KILOBYTE + end + alias :kilobyte :kilobytes + + # Returns the number of bytes equivalent to the megabytes provided. + # + # 2.megabytes # => 2_097_152 + def megabytes + self * MEGABYTE + end + alias :megabyte :megabytes + + # Returns the number of bytes equivalent to the gigabytes provided. + # + # 2.gigabytes # => 2_147_483_648 + def gigabytes + self * GIGABYTE + end + alias :gigabyte :gigabytes + + # Returns the number of bytes equivalent to the terabytes provided. + # + # 2.terabytes # => 2_199_023_255_552 + def terabytes + self * TERABYTE + end + alias :terabyte :terabytes + + # Returns the number of bytes equivalent to the petabytes provided. + # + # 2.petabytes # => 2_251_799_813_685_248 + def petabytes + self * PETABYTE + end + alias :petabyte :petabytes + + # Returns the number of bytes equivalent to the exabytes provided. + # + # 2.exabytes # => 2_305_843_009_213_693_952 + def exabytes + self * EXABYTE + end + alias :exabyte :exabytes +end diff --git a/activesupport/lib/active_support/core_ext/numeric/conversions.rb b/activesupport/lib/active_support/core_ext/numeric/conversions.rb new file mode 100644 index 0000000000..8acad6164a --- /dev/null +++ b/activesupport/lib/active_support/core_ext/numeric/conversions.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "active_support/core_ext/big_decimal/conversions" +require "active_support/number_helper" +require "active_support/core_ext/module/deprecation" + +module ActiveSupport + module NumericWithFormat + # Provides options for converting numbers into formatted strings. + # Options are provided for phone numbers, currency, percentage, + # precision, positional notation, file size and pretty printing. + # + # ==== Options + # + # For details on which formats use which options, see ActiveSupport::NumberHelper + # + # ==== Examples + # + # Phone Numbers: + # 5551234.to_s(:phone) # => "555-1234" + # 1235551234.to_s(:phone) # => "123-555-1234" + # 1235551234.to_s(:phone, area_code: true) # => "(123) 555-1234" + # 1235551234.to_s(:phone, delimiter: ' ') # => "123 555 1234" + # 1235551234.to_s(:phone, area_code: true, extension: 555) # => "(123) 555-1234 x 555" + # 1235551234.to_s(:phone, country_code: 1) # => "+1-123-555-1234" + # 1235551234.to_s(:phone, country_code: 1, extension: 1343, delimiter: '.') + # # => "+1.123.555.1234 x 1343" + # + # Currency: + # 1234567890.50.to_s(:currency) # => "$1,234,567,890.50" + # 1234567890.506.to_s(:currency) # => "$1,234,567,890.51" + # 1234567890.506.to_s(:currency, precision: 3) # => "$1,234,567,890.506" + # 1234567890.506.to_s(:currency, locale: :fr) # => "1 234 567 890,51 €" + # -1234567890.50.to_s(:currency, negative_format: '(%u%n)') + # # => "($1,234,567,890.50)" + # 1234567890.50.to_s(:currency, unit: '£', separator: ',', delimiter: '') + # # => "£1234567890,50" + # 1234567890.50.to_s(:currency, unit: '£', separator: ',', delimiter: '', format: '%n %u') + # # => "1234567890,50 £" + # + # Percentage: + # 100.to_s(:percentage) # => "100.000%" + # 100.to_s(:percentage, precision: 0) # => "100%" + # 1000.to_s(:percentage, delimiter: '.', separator: ',') # => "1.000,000%" + # 302.24398923423.to_s(:percentage, precision: 5) # => "302.24399%" + # 1000.to_s(:percentage, locale: :fr) # => "1 000,000%" + # 100.to_s(:percentage, format: '%n %') # => "100.000 %" + # + # Delimited: + # 12345678.to_s(:delimited) # => "12,345,678" + # 12345678.05.to_s(:delimited) # => "12,345,678.05" + # 12345678.to_s(:delimited, delimiter: '.') # => "12.345.678" + # 12345678.to_s(:delimited, delimiter: ',') # => "12,345,678" + # 12345678.05.to_s(:delimited, separator: ' ') # => "12,345,678 05" + # 12345678.05.to_s(:delimited, locale: :fr) # => "12 345 678,05" + # 98765432.98.to_s(:delimited, delimiter: ' ', separator: ',') + # # => "98 765 432,98" + # + # Rounded: + # 111.2345.to_s(:rounded) # => "111.235" + # 111.2345.to_s(:rounded, precision: 2) # => "111.23" + # 13.to_s(:rounded, precision: 5) # => "13.00000" + # 389.32314.to_s(:rounded, precision: 0) # => "389" + # 111.2345.to_s(:rounded, significant: true) # => "111" + # 111.2345.to_s(:rounded, precision: 1, significant: true) # => "100" + # 13.to_s(:rounded, precision: 5, significant: true) # => "13.000" + # 111.234.to_s(:rounded, locale: :fr) # => "111,234" + # 13.to_s(:rounded, precision: 5, significant: true, strip_insignificant_zeros: true) + # # => "13" + # 389.32314.to_s(:rounded, precision: 4, significant: true) # => "389.3" + # 1111.2345.to_s(:rounded, precision: 2, separator: ',', delimiter: '.') + # # => "1.111,23" + # + # Human-friendly size in Bytes: + # 123.to_s(:human_size) # => "123 Bytes" + # 1234.to_s(:human_size) # => "1.21 KB" + # 12345.to_s(:human_size) # => "12.1 KB" + # 1234567.to_s(:human_size) # => "1.18 MB" + # 1234567890.to_s(:human_size) # => "1.15 GB" + # 1234567890123.to_s(:human_size) # => "1.12 TB" + # 1234567890123456.to_s(:human_size) # => "1.1 PB" + # 1234567890123456789.to_s(:human_size) # => "1.07 EB" + # 1234567.to_s(:human_size, precision: 2) # => "1.2 MB" + # 483989.to_s(:human_size, precision: 2) # => "470 KB" + # 1234567.to_s(:human_size, precision: 2, separator: ',') # => "1,2 MB" + # 1234567890123.to_s(:human_size, precision: 5) # => "1.1228 TB" + # 524288000.to_s(:human_size, precision: 5) # => "500 MB" + # + # Human-friendly format: + # 123.to_s(:human) # => "123" + # 1234.to_s(:human) # => "1.23 Thousand" + # 12345.to_s(:human) # => "12.3 Thousand" + # 1234567.to_s(:human) # => "1.23 Million" + # 1234567890.to_s(:human) # => "1.23 Billion" + # 1234567890123.to_s(:human) # => "1.23 Trillion" + # 1234567890123456.to_s(:human) # => "1.23 Quadrillion" + # 1234567890123456789.to_s(:human) # => "1230 Quadrillion" + # 489939.to_s(:human, precision: 2) # => "490 Thousand" + # 489939.to_s(:human, precision: 4) # => "489.9 Thousand" + # 1234567.to_s(:human, precision: 4, + # significant: false) # => "1.2346 Million" + # 1234567.to_s(:human, precision: 1, + # separator: ',', + # significant: false) # => "1,2 Million" + def to_s(format = nil, options = nil) + case format + when nil + super() + when Integer, String + super(format) + when :phone + ActiveSupport::NumberHelper.number_to_phone(self, options || {}) + when :currency + ActiveSupport::NumberHelper.number_to_currency(self, options || {}) + when :percentage + ActiveSupport::NumberHelper.number_to_percentage(self, options || {}) + when :delimited + ActiveSupport::NumberHelper.number_to_delimited(self, options || {}) + when :rounded + ActiveSupport::NumberHelper.number_to_rounded(self, options || {}) + when :human + ActiveSupport::NumberHelper.number_to_human(self, options || {}) + when :human_size + ActiveSupport::NumberHelper.number_to_human_size(self, options || {}) + when Symbol + super() + else + super(format) + end + end + end +end + +Integer.prepend ActiveSupport::NumericWithFormat +Float.prepend ActiveSupport::NumericWithFormat +BigDecimal.prepend ActiveSupport::NumericWithFormat diff --git a/activesupport/lib/active_support/core_ext/numeric/inquiry.rb b/activesupport/lib/active_support/core_ext/numeric/inquiry.rb new file mode 100644 index 0000000000..6b5240d051 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/numeric/inquiry.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "active_support/deprecation" + +ActiveSupport::Deprecation.warn "Ruby 2.5+ (required by Rails 6) provides Numeric#positive? and Numeric#negative? natively, so requiring active_support/core_ext/numeric/inquiry is no longer necessary. Requiring it will raise LoadError in Rails 6.1." diff --git a/activesupport/lib/active_support/core_ext/numeric/time.rb b/activesupport/lib/active_support/core_ext/numeric/time.rb new file mode 100644 index 0000000000..bc4627f7a2 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/numeric/time.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "active_support/duration" +require "active_support/core_ext/time/calculations" +require "active_support/core_ext/time/acts_like" +require "active_support/core_ext/date/calculations" +require "active_support/core_ext/date/acts_like" + +class Numeric + # Returns a Duration instance matching the number of seconds provided. + # + # 2.seconds # => 2 seconds + def seconds + ActiveSupport::Duration.seconds(self) + end + alias :second :seconds + + # Returns a Duration instance matching the number of minutes provided. + # + # 2.minutes # => 2 minutes + def minutes + ActiveSupport::Duration.minutes(self) + end + alias :minute :minutes + + # Returns a Duration instance matching the number of hours provided. + # + # 2.hours # => 2 hours + def hours + ActiveSupport::Duration.hours(self) + end + alias :hour :hours + + # Returns a Duration instance matching the number of days provided. + # + # 2.days # => 2 days + def days + ActiveSupport::Duration.days(self) + end + alias :day :days + + # Returns a Duration instance matching the number of weeks provided. + # + # 2.weeks # => 2 weeks + def weeks + ActiveSupport::Duration.weeks(self) + end + alias :week :weeks + + # Returns a Duration instance matching the number of fortnights provided. + # + # 2.fortnights # => 4 weeks + def fortnights + ActiveSupport::Duration.weeks(self * 2) + end + alias :fortnight :fortnights + + # Returns the number of milliseconds equivalent to the seconds provided. + # Used with the standard time durations. + # + # 2.in_milliseconds # => 2000 + # 1.hour.in_milliseconds # => 3600000 + def in_milliseconds + self * 1000 + end +end diff --git a/activesupport/lib/active_support/core_ext/object.rb b/activesupport/lib/active_support/core_ext/object.rb new file mode 100644 index 0000000000..efd34cc692 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/acts_like" +require "active_support/core_ext/object/blank" +require "active_support/core_ext/object/duplicable" +require "active_support/core_ext/object/deep_dup" +require "active_support/core_ext/object/try" +require "active_support/core_ext/object/inclusion" + +require "active_support/core_ext/object/conversions" +require "active_support/core_ext/object/instance_variables" + +require "active_support/core_ext/object/json" +require "active_support/core_ext/object/to_param" +require "active_support/core_ext/object/to_query" +require "active_support/core_ext/object/with_options" diff --git a/activesupport/lib/active_support/core_ext/object/acts_like.rb b/activesupport/lib/active_support/core_ext/object/acts_like.rb new file mode 100644 index 0000000000..403ee20e39 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/acts_like.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Object + # A duck-type assistant method. For example, Active Support extends Date + # to define an <tt>acts_like_date?</tt> method, and extends Time to define + # <tt>acts_like_time?</tt>. As a result, we can do <tt>x.acts_like?(:time)</tt> and + # <tt>x.acts_like?(:date)</tt> to do duck-type-safe comparisons, since classes that + # we want to act like Time simply need to define an <tt>acts_like_time?</tt> method. + def acts_like?(duck) + case duck + when :time + respond_to? :acts_like_time? + when :date + respond_to? :acts_like_date? + when :string + respond_to? :acts_like_string? + else + respond_to? :"acts_like_#{duck}?" + end + end +end diff --git a/activesupport/lib/active_support/core_ext/object/blank.rb b/activesupport/lib/active_support/core_ext/object/blank.rb new file mode 100644 index 0000000000..f36fef6cc9 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/blank.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "concurrent/map" + +class Object + # An object is blank if it's false, empty, or a whitespace string. + # For example, +nil+, '', ' ', [], {}, and +false+ are all blank. + # + # This simplifies + # + # !address || address.empty? + # + # to + # + # address.blank? + # + # @return [true, false] + def blank? + respond_to?(:empty?) ? !!empty? : !self + end + + # An object is present if it's not blank. + # + # @return [true, false] + def present? + !blank? + end + + # Returns the receiver if it's present otherwise returns +nil+. + # <tt>object.presence</tt> is equivalent to + # + # object.present? ? object : nil + # + # For example, something like + # + # state = params[:state] if params[:state].present? + # country = params[:country] if params[:country].present? + # region = state || country || 'US' + # + # becomes + # + # region = params[:state].presence || params[:country].presence || 'US' + # + # @return [Object] + def presence + self if present? + end +end + +class NilClass + # +nil+ is blank: + # + # nil.blank? # => true + # + # @return [true] + def blank? + true + end +end + +class FalseClass + # +false+ is blank: + # + # false.blank? # => true + # + # @return [true] + def blank? + true + end +end + +class TrueClass + # +true+ is not blank: + # + # true.blank? # => false + # + # @return [false] + def blank? + false + end +end + +class Array + # An array is blank if it's empty: + # + # [].blank? # => true + # [1,2,3].blank? # => false + # + # @return [true, false] + alias_method :blank?, :empty? +end + +class Hash + # A hash is blank if it's empty: + # + # {}.blank? # => true + # { key: 'value' }.blank? # => false + # + # @return [true, false] + alias_method :blank?, :empty? +end + +class String + BLANK_RE = /\A[[:space:]]*\z/ + ENCODED_BLANKS = Concurrent::Map.new do |h, enc| + h[enc] = Regexp.new(BLANK_RE.source.encode(enc), BLANK_RE.options | Regexp::FIXEDENCODING) + end + + # A string is blank if it's empty or contains whitespaces only: + # + # ''.blank? # => true + # ' '.blank? # => true + # "\t\n\r".blank? # => true + # ' blah '.blank? # => false + # + # Unicode whitespace is supported: + # + # "\u00a0".blank? # => true + # + # @return [true, false] + def blank? + # The regexp that matches blank strings is expensive. For the case of empty + # strings we can speed up this method (~3.5x) with an empty? call. The + # penalty for the rest of strings is marginal. + empty? || + begin + BLANK_RE.match?(self) + rescue Encoding::CompatibilityError + ENCODED_BLANKS[self.encoding].match?(self) + end + end +end + +class Numeric #:nodoc: + # No number is blank: + # + # 1.blank? # => false + # 0.blank? # => false + # + # @return [false] + def blank? + false + end +end + +class Time #:nodoc: + # No Time is blank: + # + # Time.now.blank? # => false + # + # @return [false] + def blank? + false + end +end diff --git a/activesupport/lib/active_support/core_ext/object/conversions.rb b/activesupport/lib/active_support/core_ext/object/conversions.rb new file mode 100644 index 0000000000..624fb8d77c --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/conversions.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/to_param" +require "active_support/core_ext/object/to_query" +require "active_support/core_ext/array/conversions" +require "active_support/core_ext/hash/conversions" diff --git a/activesupport/lib/active_support/core_ext/object/deep_dup.rb b/activesupport/lib/active_support/core_ext/object/deep_dup.rb new file mode 100644 index 0000000000..c66c5eb2d9 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/deep_dup.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/duplicable" + +class Object + # Returns a deep copy of object if it's duplicable. If it's + # not duplicable, returns +self+. + # + # object = Object.new + # dup = object.deep_dup + # dup.instance_variable_set(:@a, 1) + # + # object.instance_variable_defined?(:@a) # => false + # dup.instance_variable_defined?(:@a) # => true + def deep_dup + duplicable? ? dup : self + end +end + +class Array + # Returns a deep copy of array. + # + # array = [1, [2, 3]] + # dup = array.deep_dup + # dup[1][2] = 4 + # + # array[1][2] # => nil + # dup[1][2] # => 4 + def deep_dup + map(&:deep_dup) + end +end + +class Hash + # Returns a deep copy of hash. + # + # hash = { a: { b: 'b' } } + # dup = hash.deep_dup + # dup[:a][:c] = 'c' + # + # hash[:a][:c] # => nil + # dup[:a][:c] # => "c" + def deep_dup + hash = dup + each_pair do |key, value| + if key.frozen? && ::String === key + hash[key] = value.deep_dup + else + hash.delete(key) + hash[key.deep_dup] = value.deep_dup + end + end + hash + end +end diff --git a/activesupport/lib/active_support/core_ext/object/duplicable.rb b/activesupport/lib/active_support/core_ext/object/duplicable.rb new file mode 100644 index 0000000000..c78ee6bbfc --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/duplicable.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +#-- +# Most objects are cloneable, but not all. For example you can't dup methods: +# +# method(:puts).dup # => TypeError: allocator undefined for Method +# +# Classes may signal their instances are not duplicable removing +dup+/+clone+ +# or raising exceptions from them. So, to dup an arbitrary object you normally +# use an optimistic approach and are ready to catch an exception, say: +# +# arbitrary_object.dup rescue object +# +# Rails dups objects in a few critical spots where they are not that arbitrary. +# That rescue is very expensive (like 40 times slower than a predicate), and it +# is often triggered. +# +# That's why we hardcode the following cases and check duplicable? instead of +# using that rescue idiom. +#++ +class Object + # Can you safely dup this object? + # + # False for method objects; + # true otherwise. + def duplicable? + true + end +end + +class NilClass + begin + nil.dup + rescue TypeError + + # +nil+ is not duplicable: + # + # nil.duplicable? # => false + # nil.dup # => TypeError: can't dup NilClass + def duplicable? + false + end + end +end + +class FalseClass + begin + false.dup + rescue TypeError + + # +false+ is not duplicable: + # + # false.duplicable? # => false + # false.dup # => TypeError: can't dup FalseClass + def duplicable? + false + end + end +end + +class TrueClass + begin + true.dup + rescue TypeError + + # +true+ is not duplicable: + # + # true.duplicable? # => false + # true.dup # => TypeError: can't dup TrueClass + def duplicable? + false + end + end +end + +class Symbol + begin + :symbol.dup + + # Some symbols couldn't be duped in Ruby 2.4.0 only, due to a bug. + # This feature check catches any regression. + "symbol_from_string".to_sym.dup + rescue TypeError + + # Symbols are not duplicable: + # + # :my_symbol.duplicable? # => false + # :my_symbol.dup # => TypeError: can't dup Symbol + def duplicable? + false + end + end +end + +class Numeric + begin + 1.dup + rescue TypeError + + # Numbers are not duplicable: + # + # 3.duplicable? # => false + # 3.dup # => TypeError: can't dup Integer + def duplicable? + false + end + end +end + +require "bigdecimal" +class BigDecimal + # BigDecimals are duplicable: + # + # BigDecimal("1.2").duplicable? # => true + # BigDecimal("1.2").dup # => #<BigDecimal:...,'0.12E1',18(18)> + def duplicable? + true + end +end + +class Method + # Methods are not duplicable: + # + # method(:puts).duplicable? # => false + # method(:puts).dup # => TypeError: allocator undefined for Method + def duplicable? + false + end +end + +class Complex + begin + Complex(1).dup + rescue TypeError + + # Complexes are not duplicable: + # + # Complex(1).duplicable? # => false + # Complex(1).dup # => TypeError: can't copy Complex + def duplicable? + false + end + end +end + +class Rational + begin + Rational(1).dup + rescue TypeError + + # Rationals are not duplicable: + # + # Rational(1).duplicable? # => false + # Rational(1).dup # => TypeError: can't copy Rational + def duplicable? + false + end + end +end diff --git a/activesupport/lib/active_support/core_ext/object/inclusion.rb b/activesupport/lib/active_support/core_ext/object/inclusion.rb new file mode 100644 index 0000000000..6064e92f20 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/inclusion.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Object + # Returns true if this object is included in the argument. Argument must be + # any object which responds to +#include?+. Usage: + # + # characters = ["Konata", "Kagami", "Tsukasa"] + # "Konata".in?(characters) # => true + # + # This will throw an +ArgumentError+ if the argument doesn't respond + # to +#include?+. + def in?(another_object) + another_object.include?(self) + rescue NoMethodError + raise ArgumentError.new("The parameter passed to #in? must respond to #include?") + end + + # Returns the receiver if it's included in the argument otherwise returns +nil+. + # Argument must be any object which responds to +#include?+. Usage: + # + # params[:bucket_type].presence_in %w( project calendar ) + # + # This will throw an +ArgumentError+ if the argument doesn't respond to +#include?+. + # + # @return [Object] + def presence_in(another_object) + in?(another_object) ? self : nil + end +end diff --git a/activesupport/lib/active_support/core_ext/object/instance_variables.rb b/activesupport/lib/active_support/core_ext/object/instance_variables.rb new file mode 100644 index 0000000000..12fdf840b5 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/instance_variables.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Object + # Returns a hash with string keys that maps instance variable names without "@" to their + # corresponding values. + # + # class C + # def initialize(x, y) + # @x, @y = x, y + # end + # end + # + # C.new(0, 1).instance_values # => {"x" => 0, "y" => 1} + def instance_values + Hash[instance_variables.map { |name| [name[1..-1], instance_variable_get(name)] }] + end + + # Returns an array of instance variable names as strings including "@". + # + # class C + # def initialize(x, y) + # @x, @y = x, y + # end + # end + # + # C.new(0, 1).instance_variable_names # => ["@y", "@x"] + def instance_variable_names + instance_variables.map(&:to_s) + end +end diff --git a/activesupport/lib/active_support/core_ext/object/json.rb b/activesupport/lib/active_support/core_ext/object/json.rb new file mode 100644 index 0000000000..416059d17b --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/json.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +# Hack to load json gem first so we can overwrite its to_json. +require "json" +require "bigdecimal" +require "uri/generic" +require "pathname" +require "active_support/core_ext/big_decimal/conversions" # for #to_s +require "active_support/core_ext/hash/except" +require "active_support/core_ext/hash/slice" +require "active_support/core_ext/object/instance_variables" +require "time" +require "active_support/core_ext/time/conversions" +require "active_support/core_ext/date_time/conversions" +require "active_support/core_ext/date/conversions" + +#-- +# The JSON gem adds a few modules to Ruby core classes containing :to_json definition, overwriting +# their default behavior. That said, we need to define the basic to_json method in all of them, +# otherwise they will always use to_json gem implementation, which is backwards incompatible in +# several cases (for instance, the JSON implementation for Hash does not work) with inheritance +# and consequently classes as ActiveSupport::OrderedHash cannot be serialized to json. +# +# On the other hand, we should avoid conflict with ::JSON.{generate,dump}(obj). Unfortunately, the +# JSON gem's encoder relies on its own to_json implementation to encode objects. Since it always +# passes a ::JSON::State object as the only argument to to_json, we can detect that and forward the +# calls to the original to_json method. +# +# It should be noted that when using ::JSON.{generate,dump} directly, ActiveSupport's encoder is +# bypassed completely. This means that as_json won't be invoked and the JSON gem will simply +# ignore any options it does not natively understand. This also means that ::JSON.{generate,dump} +# should give exactly the same results with or without active support. + +module ActiveSupport + module ToJsonWithActiveSupportEncoder # :nodoc: + def to_json(options = nil) + if options.is_a?(::JSON::State) + # Called from JSON.{generate,dump}, forward it to JSON gem's to_json + super(options) + else + # to_json is being invoked directly, use ActiveSupport's encoder + ActiveSupport::JSON.encode(self, options) + end + end + end +end + +[Object, Array, FalseClass, Float, Hash, Integer, NilClass, String, TrueClass, Enumerable].reverse_each do |klass| + klass.prepend(ActiveSupport::ToJsonWithActiveSupportEncoder) +end + +class Object + def as_json(options = nil) #:nodoc: + if respond_to?(:to_hash) + to_hash.as_json(options) + else + instance_values.as_json(options) + end + end +end + +class Struct #:nodoc: + def as_json(options = nil) + Hash[members.zip(values)].as_json(options) + end +end + +class TrueClass + def as_json(options = nil) #:nodoc: + self + end +end + +class FalseClass + def as_json(options = nil) #:nodoc: + self + end +end + +class NilClass + def as_json(options = nil) #:nodoc: + self + end +end + +class String + def as_json(options = nil) #:nodoc: + self + end +end + +class Symbol + def as_json(options = nil) #:nodoc: + to_s + end +end + +class Numeric + def as_json(options = nil) #:nodoc: + self + end +end + +class Float + # Encoding Infinity or NaN to JSON should return "null". The default returns + # "Infinity" or "NaN" which are not valid JSON. + def as_json(options = nil) #:nodoc: + finite? ? self : nil + end +end + +class BigDecimal + # A BigDecimal would be naturally represented as a JSON number. Most libraries, + # however, parse non-integer JSON numbers directly as floats. Clients using + # those libraries would get in general a wrong number and no way to recover + # other than manually inspecting the string with the JSON code itself. + # + # That's why a JSON string is returned. The JSON literal is not numeric, but + # if the other end knows by contract that the data is supposed to be a + # BigDecimal, it still has the chance to post-process the string and get the + # real value. + def as_json(options = nil) #:nodoc: + finite? ? to_s : nil + end +end + +class Regexp + def as_json(options = nil) #:nodoc: + to_s + end +end + +module Enumerable + def as_json(options = nil) #:nodoc: + to_a.as_json(options) + end +end + +class IO + def as_json(options = nil) #:nodoc: + to_s + end +end + +class Range + def as_json(options = nil) #:nodoc: + to_s + end +end + +class Array + def as_json(options = nil) #:nodoc: + map { |v| options ? v.as_json(options.dup) : v.as_json } + end +end + +class Hash + def as_json(options = nil) #:nodoc: + # create a subset of the hash by applying :only or :except + subset = if options + if attrs = options[:only] + slice(*Array(attrs)) + elsif attrs = options[:except] + except(*Array(attrs)) + else + self + end + else + self + end + + Hash[subset.map { |k, v| [k.to_s, options ? v.as_json(options.dup) : v.as_json] }] + end +end + +class Time + def as_json(options = nil) #:nodoc: + if ActiveSupport::JSON::Encoding.use_standard_json_time_format + xmlschema(ActiveSupport::JSON::Encoding.time_precision) + else + %(#{strftime("%Y/%m/%d %H:%M:%S")} #{formatted_offset(false)}) + end + end +end + +class Date + def as_json(options = nil) #:nodoc: + if ActiveSupport::JSON::Encoding.use_standard_json_time_format + strftime("%Y-%m-%d") + else + strftime("%Y/%m/%d") + end + end +end + +class DateTime + def as_json(options = nil) #:nodoc: + if ActiveSupport::JSON::Encoding.use_standard_json_time_format + xmlschema(ActiveSupport::JSON::Encoding.time_precision) + else + strftime("%Y/%m/%d %H:%M:%S %z") + end + end +end + +class URI::Generic #:nodoc: + def as_json(options = nil) + to_s + end +end + +class Pathname #:nodoc: + def as_json(options = nil) + to_s + end +end + +class Process::Status #:nodoc: + def as_json(options = nil) + { exitstatus: exitstatus, pid: pid } + end +end + +class Exception + def as_json(options = nil) + to_s + end +end diff --git a/activesupport/lib/active_support/core_ext/object/to_param.rb b/activesupport/lib/active_support/core_ext/object/to_param.rb new file mode 100644 index 0000000000..6d2bdd70f3 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/to_param.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/to_query" diff --git a/activesupport/lib/active_support/core_ext/object/to_query.rb b/activesupport/lib/active_support/core_ext/object/to_query.rb new file mode 100644 index 0000000000..bac6ff9c97 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/to_query.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "cgi" + +class Object + # Alias of <tt>to_s</tt>. + def to_param + to_s + end + + # Converts an object into a string suitable for use as a URL query string, + # using the given <tt>key</tt> as the param name. + def to_query(key) + "#{CGI.escape(key.to_param)}=#{CGI.escape(to_param.to_s)}" + end +end + +class NilClass + # Returns +self+. + def to_param + self + end +end + +class TrueClass + # Returns +self+. + def to_param + self + end +end + +class FalseClass + # Returns +self+. + def to_param + self + end +end + +class Array + # Calls <tt>to_param</tt> on all its elements and joins the result with + # slashes. This is used by <tt>url_for</tt> in Action Pack. + def to_param + collect(&:to_param).join "/" + end + + # Converts an array into a string suitable for use as a URL query string, + # using the given +key+ as the param name. + # + # ['Rails', 'coding'].to_query('hobbies') # => "hobbies%5B%5D=Rails&hobbies%5B%5D=coding" + def to_query(key) + prefix = "#{key}[]" + + if empty? + nil.to_query(prefix) + else + collect { |value| value.to_query(prefix) }.join "&" + end + end +end + +class Hash + # Returns a string representation of the receiver suitable for use as a URL + # query string: + # + # {name: 'David', nationality: 'Danish'}.to_query + # # => "name=David&nationality=Danish" + # + # An optional namespace can be passed to enclose key names: + # + # {name: 'David', nationality: 'Danish'}.to_query('user') + # # => "user%5Bname%5D=David&user%5Bnationality%5D=Danish" + # + # The string pairs "key=value" that conform the query string + # are sorted lexicographically in ascending order. + # + # This method is also aliased as +to_param+. + def to_query(namespace = nil) + query = collect do |key, value| + unless (value.is_a?(Hash) || value.is_a?(Array)) && value.empty? + value.to_query(namespace ? "#{namespace}[#{key}]" : key) + end + end.compact + + query.sort! unless namespace.to_s.include?("[]") + query.join("&") + end + + alias_method :to_param, :to_query +end diff --git a/activesupport/lib/active_support/core_ext/object/try.rb b/activesupport/lib/active_support/core_ext/object/try.rb new file mode 100644 index 0000000000..ef8a1f476d --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/try.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "delegate" + +module ActiveSupport + module Tryable #:nodoc: + def try(method_name = nil, *args, &b) + if method_name.nil? && block_given? + if b.arity == 0 + instance_eval(&b) + else + yield self + end + elsif respond_to?(method_name) + public_send(method_name, *args, &b) + end + end + + def try!(method_name = nil, *args, &b) + if method_name.nil? && block_given? + if b.arity == 0 + instance_eval(&b) + else + yield self + end + else + public_send(method_name, *args, &b) + end + end + end +end + +class Object + include ActiveSupport::Tryable + + ## + # :method: try + # + # :call-seq: + # try(*a, &b) + # + # Invokes the public method whose name goes as first argument just like + # +public_send+ does, except that if the receiver does not respond to it the + # call returns +nil+ rather than raising an exception. + # + # This method is defined to be able to write + # + # @person.try(:name) + # + # instead of + # + # @person.name if @person + # + # +try+ calls can be chained: + # + # @person.try(:spouse).try(:name) + # + # instead of + # + # @person.spouse.name if @person && @person.spouse + # + # +try+ will also return +nil+ if the receiver does not respond to the method: + # + # @person.try(:non_existing_method) # => nil + # + # instead of + # + # @person.non_existing_method if @person.respond_to?(:non_existing_method) # => nil + # + # +try+ returns +nil+ when called on +nil+ regardless of whether it responds + # to the method: + # + # nil.try(:to_i) # => nil, rather than 0 + # + # Arguments and blocks are forwarded to the method if invoked: + # + # @posts.try(:each_slice, 2) do |a, b| + # ... + # end + # + # The number of arguments in the signature must match. If the object responds + # to the method the call is attempted and +ArgumentError+ is still raised + # in case of argument mismatch. + # + # If +try+ is called without arguments it yields the receiver to a given + # block unless it is +nil+: + # + # @person.try do |p| + # ... + # end + # + # You can also call try with a block without accepting an argument, and the block + # will be instance_eval'ed instead: + # + # @person.try { upcase.truncate(50) } + # + # Please also note that +try+ is defined on +Object+. Therefore, it won't work + # with instances of classes that do not have +Object+ among their ancestors, + # like direct subclasses of +BasicObject+. + + ## + # :method: try! + # + # :call-seq: + # try!(*a, &b) + # + # Same as #try, but raises a +NoMethodError+ exception if the receiver is + # not +nil+ and does not implement the tried method. + # + # "a".try!(:upcase) # => "A" + # nil.try!(:upcase) # => nil + # 123.try!(:upcase) # => NoMethodError: undefined method `upcase' for 123:Integer +end + +class Delegator + include ActiveSupport::Tryable + + ## + # :method: try + # + # :call-seq: + # try(a*, &b) + # + # See Object#try + + ## + # :method: try! + # + # :call-seq: + # try!(a*, &b) + # + # See Object#try! +end + +class NilClass + # Calling +try+ on +nil+ always returns +nil+. + # It becomes especially helpful when navigating through associations that may return +nil+. + # + # nil.try(:name) # => nil + # + # Without +try+ + # @person && @person.children.any? && @person.children.first.name + # + # With +try+ + # @person.try(:children).try(:first).try(:name) + def try(method_name = nil, *args) + nil + end + + # Calling +try!+ on +nil+ always returns +nil+. + # + # nil.try!(:name) # => nil + def try!(method_name = nil, *args) + nil + end +end diff --git a/activesupport/lib/active_support/core_ext/object/with_options.rb b/activesupport/lib/active_support/core_ext/object/with_options.rb new file mode 100644 index 0000000000..1d46add6e0 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/object/with_options.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "active_support/option_merger" + +class Object + # An elegant way to factor duplication out of options passed to a series of + # method calls. Each method called in the block, with the block variable as + # the receiver, will have its options merged with the default +options+ hash + # provided. Each method called on the block variable must take an options + # hash as its final argument. + # + # Without <tt>with_options</tt>, this code contains duplication: + # + # class Account < ActiveRecord::Base + # has_many :customers, dependent: :destroy + # has_many :products, dependent: :destroy + # has_many :invoices, dependent: :destroy + # has_many :expenses, dependent: :destroy + # end + # + # Using <tt>with_options</tt>, we can remove the duplication: + # + # class Account < ActiveRecord::Base + # with_options dependent: :destroy do |assoc| + # assoc.has_many :customers + # assoc.has_many :products + # assoc.has_many :invoices + # assoc.has_many :expenses + # end + # end + # + # It can also be used with an explicit receiver: + # + # I18n.with_options locale: user.locale, scope: 'newsletter' do |i18n| + # subject i18n.t :subject + # body i18n.t :body, user_name: user.name + # end + # + # When you don't pass an explicit receiver, it executes the whole block + # in merging options context: + # + # class Account < ActiveRecord::Base + # with_options dependent: :destroy do + # has_many :customers + # has_many :products + # has_many :invoices + # has_many :expenses + # end + # end + # + # <tt>with_options</tt> can also be nested since the call is forwarded to its receiver. + # + # NOTE: Each nesting level will merge inherited defaults in addition to their own. + # + # class Post < ActiveRecord::Base + # with_options if: :persisted?, length: { minimum: 50 } do + # validates :content, if: -> { content.present? } + # end + # end + # + # The code is equivalent to: + # + # validates :content, length: { minimum: 50 }, if: -> { content.present? } + # + # Hence the inherited default for +if+ key is ignored. + # + # NOTE: You cannot call class methods implicitly inside of with_options. + # You can access these methods using the class name instead: + # + # class Phone < ActiveRecord::Base + # enum phone_number_type: { home: 0, office: 1, mobile: 2 } + # + # with_options presence: true do + # validates :phone_number_type, inclusion: { in: Phone.phone_number_types.keys } + # end + # end + # + def with_options(options, &block) + option_merger = ActiveSupport::OptionMerger.new(self, options) + block.arity.zero? ? option_merger.instance_eval(&block) : block.call(option_merger) + end +end diff --git a/activesupport/lib/active_support/core_ext/range.rb b/activesupport/lib/active_support/core_ext/range.rb new file mode 100644 index 0000000000..78814fd189 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "active_support/core_ext/range/conversions" +require "active_support/core_ext/range/compare_range" +require "active_support/core_ext/range/include_time_with_zone" +require "active_support/core_ext/range/overlaps" +require "active_support/core_ext/range/each" diff --git a/activesupport/lib/active_support/core_ext/range/compare_range.rb b/activesupport/lib/active_support/core_ext/range/compare_range.rb new file mode 100644 index 0000000000..6f6d2a27bb --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/compare_range.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module ActiveSupport + module CompareWithRange + # Extends the default Range#=== to support range comparisons. + # (1..5) === (1..5) # => true + # (1..5) === (2..3) # => true + # (1..5) === (2..6) # => false + # + # The native Range#=== behavior is untouched. + # ('a'..'f') === ('c') # => true + # (5..9) === (11) # => false + def ===(value) + if value.is_a?(::Range) + # 1...10 includes 1..9 but it does not include 1..10. + operator = exclude_end? && !value.exclude_end? ? :< : :<= + super(value.first) && value.last.send(operator, last) + else + super + end + end + + # Extends the default Range#include? to support range comparisons. + # (1..5).include?(1..5) # => true + # (1..5).include?(2..3) # => true + # (1..5).include?(2..6) # => false + # + # The native Range#include? behavior is untouched. + # ('a'..'f').include?('c') # => true + # (5..9).include?(11) # => false + def include?(value) + if value.is_a?(::Range) + # 1...10 includes 1..9 but it does not include 1..10. + operator = exclude_end? && !value.exclude_end? ? :< : :<= + super(value.first) && value.last.send(operator, last) + else + super + end + end + + # Extends the default Range#cover? to support range comparisons. + # (1..5).cover?(1..5) # => true + # (1..5).cover?(2..3) # => true + # (1..5).cover?(2..6) # => false + # + # The native Range#cover? behavior is untouched. + # ('a'..'f').cover?('c') # => true + # (5..9).cover?(11) # => false + def cover?(value) + if value.is_a?(::Range) + # 1...10 covers 1..9 but it does not cover 1..10. + operator = exclude_end? && !value.exclude_end? ? :< : :<= + super(value.first) && value.last.send(operator, last) + else + super + end + end + end +end + +Range.prepend(ActiveSupport::CompareWithRange) diff --git a/activesupport/lib/active_support/core_ext/range/conversions.rb b/activesupport/lib/active_support/core_ext/range/conversions.rb new file mode 100644 index 0000000000..024e32db40 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/conversions.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ActiveSupport + module RangeWithFormat + RANGE_FORMATS = { + db: -> (start, stop) do + case start + when String then "BETWEEN '#{start}' AND '#{stop}'" + else + "BETWEEN '#{start.to_s(:db)}' AND '#{stop.to_s(:db)}'" + end + end + } + + # Convert range to a formatted string. See RANGE_FORMATS for predefined formats. + # + # range = (1..100) # => 1..100 + # + # range.to_s # => "1..100" + # range.to_s(:db) # => "BETWEEN '1' AND '100'" + # + # == Adding your own range formats to to_s + # You can add your own formats to the Range::RANGE_FORMATS hash. + # Use the format name as the hash key and a Proc instance. + # + # # config/initializers/range_formats.rb + # Range::RANGE_FORMATS[:short] = ->(start, stop) { "Between #{start.to_s(:db)} and #{stop.to_s(:db)}" } + def to_s(format = :default) + if formatter = RANGE_FORMATS[format] + formatter.call(first, last) + else + super() + end + end + + alias_method :to_default_s, :to_s + alias_method :to_formatted_s, :to_s + end +end + +Range.prepend(ActiveSupport::RangeWithFormat) diff --git a/activesupport/lib/active_support/core_ext/range/each.rb b/activesupport/lib/active_support/core_ext/range/each.rb new file mode 100644 index 0000000000..2f22cd0e92 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/each.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "active_support/time_with_zone" + +module ActiveSupport + module EachTimeWithZone #:nodoc: + def each(&block) + ensure_iteration_allowed + super + end + + def step(n = 1, &block) + ensure_iteration_allowed + super + end + + private + + def ensure_iteration_allowed + raise TypeError, "can't iterate from #{first.class}" if first.is_a?(TimeWithZone) + end + end +end + +Range.prepend(ActiveSupport::EachTimeWithZone) diff --git a/activesupport/lib/active_support/core_ext/range/include_range.rb b/activesupport/lib/active_support/core_ext/range/include_range.rb new file mode 100644 index 0000000000..2da2c587a3 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/include_range.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "active_support/deprecation" + +ActiveSupport::Deprecation.warn "You have required `active_support/core_ext/range/include_range`. " \ +"This file will be removed in Rails 6.1. You should require `active_support/core_ext/range/compare_range` " \ + "instead." + +require "active_support/core_ext/range/compare_range" diff --git a/activesupport/lib/active_support/core_ext/range/include_time_with_zone.rb b/activesupport/lib/active_support/core_ext/range/include_time_with_zone.rb new file mode 100644 index 0000000000..5f80acf68e --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/include_time_with_zone.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "active_support/time_with_zone" + +module ActiveSupport + module IncludeTimeWithZone #:nodoc: + # Extends the default Range#include? to support ActiveSupport::TimeWithZone. + # + # (1.hour.ago..1.hour.from_now).include?(Time.current) # => true + # + def include?(value) + if first.is_a?(TimeWithZone) + cover?(value) + elsif last.is_a?(TimeWithZone) + cover?(value) + else + super + end + end + end +end + +Range.prepend(ActiveSupport::IncludeTimeWithZone) diff --git a/activesupport/lib/active_support/core_ext/range/overlaps.rb b/activesupport/lib/active_support/core_ext/range/overlaps.rb new file mode 100644 index 0000000000..f753607f8b --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/overlaps.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Range + # Compare two ranges and see if they overlap each other + # (1..5).overlaps?(4..6) # => true + # (1..5).overlaps?(7..9) # => false + def overlaps?(other) + cover?(other.first) || other.cover?(first) + end +end diff --git a/activesupport/lib/active_support/core_ext/regexp.rb b/activesupport/lib/active_support/core_ext/regexp.rb new file mode 100644 index 0000000000..d92943c7ae --- /dev/null +++ b/activesupport/lib/active_support/core_ext/regexp.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Regexp #:nodoc: + def multiline? + options & MULTILINE == MULTILINE + end +end diff --git a/activesupport/lib/active_support/core_ext/securerandom.rb b/activesupport/lib/active_support/core_ext/securerandom.rb new file mode 100644 index 0000000000..b4a491f5fd --- /dev/null +++ b/activesupport/lib/active_support/core_ext/securerandom.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "securerandom" + +module SecureRandom + BASE58_ALPHABET = ("0".."9").to_a + ("A".."Z").to_a + ("a".."z").to_a - ["0", "O", "I", "l"] + # SecureRandom.base58 generates a random base58 string. + # + # The argument _n_ specifies the length, of the random string to be generated. + # + # If _n_ is not specified or is +nil+, 16 is assumed. It may be larger in the future. + # + # The result may contain alphanumeric characters except 0, O, I and l + # + # p SecureRandom.base58 # => "4kUgL2pdQMSCQtjE" + # p SecureRandom.base58(24) # => "77TMHrHJFvFDwodq8w7Ev2m7" + # + def self.base58(n = 16) + SecureRandom.random_bytes(n).unpack("C*").map do |byte| + idx = byte % 64 + idx = SecureRandom.random_number(58) if idx >= 58 + BASE58_ALPHABET[idx] + end.join + end +end diff --git a/activesupport/lib/active_support/core_ext/string.rb b/activesupport/lib/active_support/core_ext/string.rb new file mode 100644 index 0000000000..757d15c51a --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/conversions" +require "active_support/core_ext/string/filters" +require "active_support/core_ext/string/multibyte" +require "active_support/core_ext/string/starts_ends_with" +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/string/access" +require "active_support/core_ext/string/behavior" +require "active_support/core_ext/string/output_safety" +require "active_support/core_ext/string/exclude" +require "active_support/core_ext/string/strip" +require "active_support/core_ext/string/inquiry" +require "active_support/core_ext/string/indent" +require "active_support/core_ext/string/zones" diff --git a/activesupport/lib/active_support/core_ext/string/access.rb b/activesupport/lib/active_support/core_ext/string/access.rb new file mode 100644 index 0000000000..4ca24028b0 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/access.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +class String + # If you pass a single integer, returns a substring of one character at that + # position. The first character of the string is at position 0, the next at + # position 1, and so on. If a range is supplied, a substring containing + # characters at offsets given by the range is returned. In both cases, if an + # offset is negative, it is counted from the end of the string. Returns +nil+ + # if the initial offset falls outside the string. Returns an empty string if + # the beginning of the range is greater than the end of the string. + # + # str = "hello" + # str.at(0) # => "h" + # str.at(1..3) # => "ell" + # str.at(-2) # => "l" + # str.at(-2..-1) # => "lo" + # str.at(5) # => nil + # str.at(5..-1) # => "" + # + # If a Regexp is given, the matching portion of the string is returned. + # If a String is given, that given string is returned if it occurs in + # the string. In both cases, +nil+ is returned if there is no match. + # + # str = "hello" + # str.at(/lo/) # => "lo" + # str.at(/ol/) # => nil + # str.at("lo") # => "lo" + # str.at("ol") # => nil + def at(position) + self[position] + end + + # Returns a substring from the given position to the end of the string. + # If the position is negative, it is counted from the end of the string. + # + # str = "hello" + # str.from(0) # => "hello" + # str.from(3) # => "lo" + # str.from(-2) # => "lo" + # + # You can mix it with +to+ method and do fun things like: + # + # str = "hello" + # str.from(0).to(-1) # => "hello" + # str.from(1).to(-2) # => "ell" + def from(position) + self[position..-1] + end + + # Returns a substring from the beginning of the string to the given position. + # If the position is negative, it is counted from the end of the string. + # + # str = "hello" + # str.to(0) # => "h" + # str.to(3) # => "hell" + # str.to(-2) # => "hell" + # + # You can mix it with +from+ method and do fun things like: + # + # str = "hello" + # str.from(0).to(-1) # => "hello" + # str.from(1).to(-2) # => "ell" + def to(position) + self[0..position] + end + + # Returns the first character. If a limit is supplied, returns a substring + # from the beginning of the string until it reaches the limit value. If the + # given limit is greater than or equal to the string length, returns a copy of self. + # + # str = "hello" + # str.first # => "h" + # str.first(1) # => "h" + # str.first(2) # => "he" + # str.first(0) # => "" + # str.first(6) # => "hello" + def first(limit = 1) + ActiveSupport::Deprecation.warn( + "Calling String#first with a negative integer limit " \ + "will raise an ArgumentError in Rails 6.1." + ) if limit < 0 + if limit == 0 + "" + elsif limit >= size + dup + else + to(limit - 1) + end + end + + # Returns the last character of the string. If a limit is supplied, returns a substring + # from the end of the string until it reaches the limit value (counting backwards). If + # the given limit is greater than or equal to the string length, returns a copy of self. + # + # str = "hello" + # str.last # => "o" + # str.last(1) # => "o" + # str.last(2) # => "lo" + # str.last(0) # => "" + # str.last(6) # => "hello" + def last(limit = 1) + ActiveSupport::Deprecation.warn( + "Calling String#last with a negative integer limit " \ + "will raise an ArgumentError in Rails 6.1." + ) if limit < 0 + if limit == 0 + "" + elsif limit >= size + dup + else + from(-limit) + end + end +end diff --git a/activesupport/lib/active_support/core_ext/string/behavior.rb b/activesupport/lib/active_support/core_ext/string/behavior.rb new file mode 100644 index 0000000000..35a5aa7840 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/behavior.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class String + # Enables more predictable duck-typing on String-like classes. See <tt>Object#acts_like?</tt>. + def acts_like_string? + true + end +end diff --git a/activesupport/lib/active_support/core_ext/string/conversions.rb b/activesupport/lib/active_support/core_ext/string/conversions.rb new file mode 100644 index 0000000000..29a88b07ad --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/conversions.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "date" +require "active_support/core_ext/time/calculations" + +class String + # Converts a string to a Time value. + # The +form+ can be either :utc or :local (default :local). + # + # The time is parsed using Time.parse method. + # If +form+ is :local, then the time is in the system timezone. + # If the date part is missing then the current date is used and if + # the time part is missing then it is assumed to be 00:00:00. + # + # "13-12-2012".to_time # => 2012-12-13 00:00:00 +0100 + # "06:12".to_time # => 2012-12-13 06:12:00 +0100 + # "2012-12-13 06:12".to_time # => 2012-12-13 06:12:00 +0100 + # "2012-12-13T06:12".to_time # => 2012-12-13 06:12:00 +0100 + # "2012-12-13T06:12".to_time(:utc) # => 2012-12-13 06:12:00 UTC + # "12/13/2012".to_time # => ArgumentError: argument out of range + def to_time(form = :local) + parts = Date._parse(self, false) + used_keys = %i(year mon mday hour min sec sec_fraction offset) + return if (parts.keys & used_keys).empty? + + now = Time.now + time = Time.new( + parts.fetch(:year, now.year), + parts.fetch(:mon, now.month), + parts.fetch(:mday, now.day), + parts.fetch(:hour, 0), + parts.fetch(:min, 0), + parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset, form == :utc ? 0 : nil) + ) + + form == :utc ? time.utc : time.to_time + end + + # Converts a string to a Date value. + # + # "1-1-2012".to_date # => Sun, 01 Jan 2012 + # "01/01/2012".to_date # => Sun, 01 Jan 2012 + # "2012-12-13".to_date # => Thu, 13 Dec 2012 + # "12/13/2012".to_date # => ArgumentError: invalid date + def to_date + ::Date.parse(self, false) unless blank? + end + + # Converts a string to a DateTime value. + # + # "1-1-2012".to_datetime # => Sun, 01 Jan 2012 00:00:00 +0000 + # "01/01/2012 23:59:59".to_datetime # => Sun, 01 Jan 2012 23:59:59 +0000 + # "2012-12-13 12:50".to_datetime # => Thu, 13 Dec 2012 12:50:00 +0000 + # "12/13/2012".to_datetime # => ArgumentError: invalid date + def to_datetime + ::DateTime.parse(self, false) unless blank? + end +end diff --git a/activesupport/lib/active_support/core_ext/string/exclude.rb b/activesupport/lib/active_support/core_ext/string/exclude.rb new file mode 100644 index 0000000000..8e462689f1 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/exclude.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class String + # The inverse of <tt>String#include?</tt>. Returns true if the string + # does not include the other string. + # + # "hello".exclude? "lo" # => false + # "hello".exclude? "ol" # => true + # "hello".exclude? ?h # => false + def exclude?(string) + !include?(string) + end +end diff --git a/activesupport/lib/active_support/core_ext/string/filters.rb b/activesupport/lib/active_support/core_ext/string/filters.rb new file mode 100644 index 0000000000..df0e79afa8 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/filters.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +class String + # Returns the string, first removing all whitespace on both ends of + # the string, and then changing remaining consecutive whitespace + # groups into one space each. + # + # Note that it handles both ASCII and Unicode whitespace. + # + # %{ Multi-line + # string }.squish # => "Multi-line string" + # " foo bar \n \t boo".squish # => "foo bar boo" + def squish + dup.squish! + end + + # Performs a destructive squish. See String#squish. + # str = " foo bar \n \t boo" + # str.squish! # => "foo bar boo" + # str # => "foo bar boo" + def squish! + gsub!(/[[:space:]]+/, " ") + strip! + self + end + + # Returns a new string with all occurrences of the patterns removed. + # str = "foo bar test" + # str.remove(" test") # => "foo bar" + # str.remove(" test", /bar/) # => "foo " + # str # => "foo bar test" + def remove(*patterns) + dup.remove!(*patterns) + end + + # Alters the string by removing all occurrences of the patterns. + # str = "foo bar test" + # str.remove!(" test", /bar/) # => "foo " + # str # => "foo " + def remove!(*patterns) + patterns.each do |pattern| + gsub! pattern, "" + end + + self + end + + # Truncates a given +text+ after a given <tt>length</tt> if +text+ is longer than <tt>length</tt>: + # + # 'Once upon a time in a world far far away'.truncate(27) + # # => "Once upon a time in a wo..." + # + # Pass a string or regexp <tt>:separator</tt> to truncate +text+ at a natural break: + # + # 'Once upon a time in a world far far away'.truncate(27, separator: ' ') + # # => "Once upon a time in a..." + # + # 'Once upon a time in a world far far away'.truncate(27, separator: /\s/) + # # => "Once upon a time in a..." + # + # The last characters will be replaced with the <tt>:omission</tt> string (defaults to "...") + # for a total length not exceeding <tt>length</tt>: + # + # 'And they found that many people were sleeping better.'.truncate(25, omission: '... (continued)') + # # => "And they f... (continued)" + def truncate(truncate_at, options = {}) + return dup unless length > truncate_at + + omission = options[:omission] || "..." + length_with_room_for_omission = truncate_at - omission.length + stop = \ + if options[:separator] + rindex(options[:separator], length_with_room_for_omission) || length_with_room_for_omission + else + length_with_room_for_omission + end + + "#{self[0, stop]}#{omission}" + end + + # Truncates +text+ to at most <tt>bytesize</tt> bytes in length without + # breaking string encoding by splitting multibyte characters or breaking + # grapheme clusters ("perceptual characters") by truncating at combining + # characters. + # + # >> "🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪".size + # => 20 + # >> "🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪".bytesize + # => 80 + # >> "🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪🔪".truncate_bytes(20) + # => "🔪🔪🔪🔪…" + # + # The truncated text ends with the <tt>:omission</tt> string, defaulting + # to "…", for a total length not exceeding <tt>bytesize</tt>. + def truncate_bytes(truncate_at, omission: "…") + omission ||= "" + + case + when bytesize <= truncate_at + dup + when omission.bytesize > truncate_at + raise ArgumentError, "Omission #{omission.inspect} is #{omission.bytesize}, larger than the truncation length of #{truncate_at} bytes" + when omission.bytesize == truncate_at + omission.dup + else + self.class.new.tap do |cut| + cut_at = truncate_at - omission.bytesize + + scan(/\X/) do |grapheme| + if cut.bytesize + grapheme.bytesize <= cut_at + cut << grapheme + else + break + end + end + + cut << omission + end + end + end + + # Truncates a given +text+ after a given number of words (<tt>words_count</tt>): + # + # 'Once upon a time in a world far far away'.truncate_words(4) + # # => "Once upon a time..." + # + # Pass a string or regexp <tt>:separator</tt> to specify a different separator of words: + # + # 'Once<br>upon<br>a<br>time<br>in<br>a<br>world'.truncate_words(5, separator: '<br>') + # # => "Once<br>upon<br>a<br>time<br>in..." + # + # The last characters will be replaced with the <tt>:omission</tt> string (defaults to "..."): + # + # 'And they found that many people were sleeping better.'.truncate_words(5, omission: '... (continued)') + # # => "And they found that many... (continued)" + def truncate_words(words_count, options = {}) + sep = options[:separator] || /\s+/ + sep = Regexp.escape(sep.to_s) unless Regexp === sep + if self =~ /\A((?>.+?#{sep}){#{words_count - 1}}.+?)#{sep}.*/m + $1 + (options[:omission] || "...") + else + dup + end + end +end diff --git a/activesupport/lib/active_support/core_ext/string/indent.rb b/activesupport/lib/active_support/core_ext/string/indent.rb new file mode 100644 index 0000000000..af9d181487 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/indent.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class String + # Same as +indent+, except it indents the receiver in-place. + # + # Returns the indented string, or +nil+ if there was nothing to indent. + def indent!(amount, indent_string = nil, indent_empty_lines = false) + indent_string = indent_string || self[/^[ \t]/] || " " + re = indent_empty_lines ? /^/ : /^(?!$)/ + gsub!(re, indent_string * amount) + end + + # Indents the lines in the receiver: + # + # <<EOS.indent(2) + # def some_method + # some_code + # end + # EOS + # # => + # def some_method + # some_code + # end + # + # The second argument, +indent_string+, specifies which indent string to + # use. The default is +nil+, which tells the method to make a guess by + # peeking at the first indented line, and fallback to a space if there is + # none. + # + # " foo".indent(2) # => " foo" + # "foo\n\t\tbar".indent(2) # => "\t\tfoo\n\t\t\t\tbar" + # "foo".indent(2, "\t") # => "\t\tfoo" + # + # While +indent_string+ is typically one space or tab, it may be any string. + # + # The third argument, +indent_empty_lines+, is a flag that says whether + # empty lines should be indented. Default is false. + # + # "foo\n\nbar".indent(2) # => " foo\n\n bar" + # "foo\n\nbar".indent(2, nil, true) # => " foo\n \n bar" + # + def indent(amount, indent_string = nil, indent_empty_lines = false) + dup.tap { |_| _.indent!(amount, indent_string, indent_empty_lines) } + end +end diff --git a/activesupport/lib/active_support/core_ext/string/inflections.rb b/activesupport/lib/active_support/core_ext/string/inflections.rb new file mode 100644 index 0000000000..8af301734a --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/inflections.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require "active_support/inflector/methods" +require "active_support/inflector/transliterate" + +# String inflections define new methods on the String class to transform names for different purposes. +# For instance, you can figure out the name of a table from the name of a class. +# +# 'ScaleScore'.tableize # => "scale_scores" +# +class String + # Returns the plural form of the word in the string. + # + # If the optional parameter +count+ is specified, + # the singular form will be returned if <tt>count == 1</tt>. + # For any other value of +count+ the plural will be returned. + # + # If the optional parameter +locale+ is specified, + # the word will be pluralized as a word of that language. + # By default, this parameter is set to <tt>:en</tt>. + # You must define your own inflection rules for languages other than English. + # + # 'post'.pluralize # => "posts" + # 'octopus'.pluralize # => "octopi" + # 'sheep'.pluralize # => "sheep" + # 'words'.pluralize # => "words" + # 'the blue mailman'.pluralize # => "the blue mailmen" + # 'CamelOctopus'.pluralize # => "CamelOctopi" + # 'apple'.pluralize(1) # => "apple" + # 'apple'.pluralize(2) # => "apples" + # 'ley'.pluralize(:es) # => "leyes" + # 'ley'.pluralize(1, :es) # => "ley" + def pluralize(count = nil, locale = :en) + locale = count if count.is_a?(Symbol) + if count == 1 + dup + else + ActiveSupport::Inflector.pluralize(self, locale) + end + end + + # The reverse of +pluralize+, returns the singular form of a word in a string. + # + # If the optional parameter +locale+ is specified, + # the word will be singularized as a word of that language. + # By default, this parameter is set to <tt>:en</tt>. + # You must define your own inflection rules for languages other than English. + # + # 'posts'.singularize # => "post" + # 'octopi'.singularize # => "octopus" + # 'sheep'.singularize # => "sheep" + # 'word'.singularize # => "word" + # 'the blue mailmen'.singularize # => "the blue mailman" + # 'CamelOctopi'.singularize # => "CamelOctopus" + # 'leyes'.singularize(:es) # => "ley" + def singularize(locale = :en) + ActiveSupport::Inflector.singularize(self, locale) + end + + # +constantize+ tries to find a declared constant with the name specified + # in the string. It raises a NameError when the name is not in CamelCase + # or is not initialized. See ActiveSupport::Inflector.constantize + # + # 'Module'.constantize # => Module + # 'Class'.constantize # => Class + # 'blargle'.constantize # => NameError: wrong constant name blargle + def constantize + ActiveSupport::Inflector.constantize(self) + end + + # +safe_constantize+ tries to find a declared constant with the name specified + # in the string. It returns +nil+ when the name is not in CamelCase + # or is not initialized. See ActiveSupport::Inflector.safe_constantize + # + # 'Module'.safe_constantize # => Module + # 'Class'.safe_constantize # => Class + # 'blargle'.safe_constantize # => nil + def safe_constantize + ActiveSupport::Inflector.safe_constantize(self) + end + + # By default, +camelize+ converts strings to UpperCamelCase. If the argument to camelize + # is set to <tt>:lower</tt> then camelize produces lowerCamelCase. + # + # +camelize+ will also convert '/' to '::' which is useful for converting paths to namespaces. + # + # 'active_record'.camelize # => "ActiveRecord" + # 'active_record'.camelize(:lower) # => "activeRecord" + # 'active_record/errors'.camelize # => "ActiveRecord::Errors" + # 'active_record/errors'.camelize(:lower) # => "activeRecord::Errors" + def camelize(first_letter = :upper) + case first_letter + when :upper + ActiveSupport::Inflector.camelize(self, true) + when :lower + ActiveSupport::Inflector.camelize(self, false) + else + raise ArgumentError, "Invalid option, use either :upper or :lower." + end + end + alias_method :camelcase, :camelize + + # Capitalizes all the words and replaces some characters in the string to create + # a nicer looking title. +titleize+ is meant for creating pretty output. It is not + # used in the Rails internals. + # + # The trailing '_id','Id'.. can be kept and capitalized by setting the + # optional parameter +keep_id_suffix+ to true. + # By default, this parameter is false. + # + # +titleize+ is also aliased as +titlecase+. + # + # 'man from the boondocks'.titleize # => "Man From The Boondocks" + # 'x-men: the last stand'.titleize # => "X Men: The Last Stand" + # 'string_ending_with_id'.titleize(keep_id_suffix: true) # => "String Ending With Id" + def titleize(keep_id_suffix: false) + ActiveSupport::Inflector.titleize(self, keep_id_suffix: keep_id_suffix) + end + alias_method :titlecase, :titleize + + # The reverse of +camelize+. Makes an underscored, lowercase form from the expression in the string. + # + # +underscore+ will also change '::' to '/' to convert namespaces to paths. + # + # 'ActiveModel'.underscore # => "active_model" + # 'ActiveModel::Errors'.underscore # => "active_model/errors" + def underscore + ActiveSupport::Inflector.underscore(self) + end + + # Replaces underscores with dashes in the string. + # + # 'puni_puni'.dasherize # => "puni-puni" + def dasherize + ActiveSupport::Inflector.dasherize(self) + end + + # Removes the module part from the constant expression in the string. + # + # 'ActiveSupport::Inflector::Inflections'.demodulize # => "Inflections" + # 'Inflections'.demodulize # => "Inflections" + # '::Inflections'.demodulize # => "Inflections" + # ''.demodulize # => '' + # + # See also +deconstantize+. + def demodulize + ActiveSupport::Inflector.demodulize(self) + end + + # Removes the rightmost segment from the constant expression in the string. + # + # 'Net::HTTP'.deconstantize # => "Net" + # '::Net::HTTP'.deconstantize # => "::Net" + # 'String'.deconstantize # => "" + # '::String'.deconstantize # => "" + # ''.deconstantize # => "" + # + # See also +demodulize+. + def deconstantize + ActiveSupport::Inflector.deconstantize(self) + end + + # Replaces special characters in a string so that it may be used as part of a 'pretty' URL. + # + # class Person + # def to_param + # "#{id}-#{name.parameterize}" + # end + # end + # + # @person = Person.find(1) + # # => #<Person id: 1, name: "Donald E. Knuth"> + # + # <%= link_to(@person.name, person_path) %> + # # => <a href="/person/1-donald-e-knuth">Donald E. Knuth</a> + # + # To preserve the case of the characters in a string, use the +preserve_case+ argument. + # + # class Person + # def to_param + # "#{id}-#{name.parameterize(preserve_case: true)}" + # end + # end + # + # @person = Person.find(1) + # # => #<Person id: 1, name: "Donald E. Knuth"> + # + # <%= link_to(@person.name, person_path) %> + # # => <a href="/person/1-Donald-E-Knuth">Donald E. Knuth</a> + def parameterize(separator: "-", preserve_case: false) + ActiveSupport::Inflector.parameterize(self, separator: separator, preserve_case: preserve_case) + end + + # Creates the name of a table like Rails does for models to table names. This method + # uses the +pluralize+ method on the last word in the string. + # + # 'RawScaledScorer'.tableize # => "raw_scaled_scorers" + # 'ham_and_egg'.tableize # => "ham_and_eggs" + # 'fancyCategory'.tableize # => "fancy_categories" + def tableize + ActiveSupport::Inflector.tableize(self) + end + + # Creates a class name from a plural table name like Rails does for table names to models. + # Note that this returns a string and not a class. (To convert to an actual class + # follow +classify+ with +constantize+.) + # + # 'ham_and_eggs'.classify # => "HamAndEgg" + # 'posts'.classify # => "Post" + def classify + ActiveSupport::Inflector.classify(self) + end + + # Capitalizes the first word, turns underscores into spaces, and (by default)strips a + # trailing '_id' if present. + # Like +titleize+, this is meant for creating pretty output. + # + # The capitalization of the first word can be turned off by setting the + # optional parameter +capitalize+ to false. + # By default, this parameter is true. + # + # The trailing '_id' can be kept and capitalized by setting the + # optional parameter +keep_id_suffix+ to true. + # By default, this parameter is false. + # + # 'employee_salary'.humanize # => "Employee salary" + # 'author_id'.humanize # => "Author" + # 'author_id'.humanize(capitalize: false) # => "author" + # '_id'.humanize # => "Id" + # 'author_id'.humanize(keep_id_suffix: true) # => "Author Id" + def humanize(capitalize: true, keep_id_suffix: false) + ActiveSupport::Inflector.humanize(self, capitalize: capitalize, keep_id_suffix: keep_id_suffix) + end + + # Converts just the first character to uppercase. + # + # 'what a Lovely Day'.upcase_first # => "What a Lovely Day" + # 'w'.upcase_first # => "W" + # ''.upcase_first # => "" + def upcase_first + ActiveSupport::Inflector.upcase_first(self) + end + + # Creates a foreign key name from a class name. + # +separate_class_name_and_id_with_underscore+ sets whether + # the method should put '_' between the name and 'id'. + # + # 'Message'.foreign_key # => "message_id" + # 'Message'.foreign_key(false) # => "messageid" + # 'Admin::Post'.foreign_key # => "post_id" + def foreign_key(separate_class_name_and_id_with_underscore = true) + ActiveSupport::Inflector.foreign_key(self, separate_class_name_and_id_with_underscore) + end +end diff --git a/activesupport/lib/active_support/core_ext/string/inquiry.rb b/activesupport/lib/active_support/core_ext/string/inquiry.rb new file mode 100644 index 0000000000..a796d5fb4f --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/inquiry.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "active_support/string_inquirer" + +class String + # Wraps the current string in the <tt>ActiveSupport::StringInquirer</tt> class, + # which gives you a prettier way to test for equality. + # + # env = 'production'.inquiry + # env.production? # => true + # env.development? # => false + def inquiry + ActiveSupport::StringInquirer.new(self) + end +end diff --git a/activesupport/lib/active_support/core_ext/string/multibyte.rb b/activesupport/lib/active_support/core_ext/string/multibyte.rb new file mode 100644 index 0000000000..6cceb46507 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/multibyte.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "active_support/multibyte" + +class String + # == Multibyte proxy + # + # +mb_chars+ is a multibyte safe proxy for string methods. + # + # It creates and returns an instance of the ActiveSupport::Multibyte::Chars class which + # encapsulates the original string. A Unicode safe version of all the String methods are defined on this proxy + # class. If the proxy class doesn't respond to a certain method, it's forwarded to the encapsulated string. + # + # >> "lj".mb_chars.upcase.to_s + # => "LJ" + # + # NOTE: Ruby 2.4 and later support native Unicode case mappings: + # + # >> "lj".upcase + # => "LJ" + # + # == Method chaining + # + # All the methods on the Chars proxy which normally return a string will return a Chars object. This allows + # method chaining on the result of any of these methods. + # + # name.mb_chars.reverse.length # => 12 + # + # == Interoperability and configuration + # + # The Chars object tries to be as interchangeable with String objects as possible: sorting and comparing between + # String and Char work like expected. The bang! methods change the internal string representation in the Chars + # object. Interoperability problems can be resolved easily with a +to_s+ call. + # + # For more information about the methods defined on the Chars proxy see ActiveSupport::Multibyte::Chars. For + # information about how to change the default Multibyte behavior see ActiveSupport::Multibyte. + def mb_chars + ActiveSupport::Multibyte.proxy_class.new(self) + end + + # Returns +true+ if string has utf_8 encoding. + # + # utf_8_str = "some string".encode "UTF-8" + # iso_str = "some string".encode "ISO-8859-1" + # + # utf_8_str.is_utf8? # => true + # iso_str.is_utf8? # => false + def is_utf8? + case encoding + when Encoding::UTF_8 + valid_encoding? + when Encoding::ASCII_8BIT, Encoding::US_ASCII + dup.force_encoding(Encoding::UTF_8).valid_encoding? + else + false + end + end +end diff --git a/activesupport/lib/active_support/core_ext/string/output_safety.rb b/activesupport/lib/active_support/core_ext/string/output_safety.rb new file mode 100644 index 0000000000..3a80de4617 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/output_safety.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "erb" +require "active_support/core_ext/kernel/singleton_class" +require "active_support/core_ext/module/redefine_method" +require "active_support/multibyte/unicode" + +class ERB + module Util + HTML_ESCAPE = { "&" => "&", ">" => ">", "<" => "<", '"' => """, "'" => "'" } + JSON_ESCAPE = { "&" => '\u0026', ">" => '\u003e', "<" => '\u003c', "\u2028" => '\u2028', "\u2029" => '\u2029' } + HTML_ESCAPE_ONCE_REGEXP = /["><']|&(?!([a-zA-Z]+|(#\d+)|(#[xX][\dA-Fa-f]+));)/ + JSON_ESCAPE_REGEXP = /[\u2028\u2029&><]/u + + # A utility method for escaping HTML tag characters. + # This method is also aliased as <tt>h</tt>. + # + # puts html_escape('is a > 0 & a < 10?') + # # => is a > 0 & a < 10? + def html_escape(s) + unwrapped_html_escape(s).html_safe + end + + silence_redefinition_of_method :h + alias h html_escape + + module_function :h + + singleton_class.silence_redefinition_of_method :html_escape + module_function :html_escape + + # HTML escapes strings but doesn't wrap them with an ActiveSupport::SafeBuffer. + # This method is not for public consumption! Seriously! + def unwrapped_html_escape(s) # :nodoc: + s = s.to_s + if s.html_safe? + s + else + CGI.escapeHTML(ActiveSupport::Multibyte::Unicode.tidy_bytes(s)) + end + end + module_function :unwrapped_html_escape + + # A utility method for escaping HTML without affecting existing escaped entities. + # + # html_escape_once('1 < 2 & 3') + # # => "1 < 2 & 3" + # + # html_escape_once('<< Accept & Checkout') + # # => "<< Accept & Checkout" + def html_escape_once(s) + result = ActiveSupport::Multibyte::Unicode.tidy_bytes(s.to_s).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) + s.html_safe? ? result.html_safe : result + end + + module_function :html_escape_once + + # A utility method for escaping HTML entities in JSON strings. Specifically, the + # &, > and < characters are replaced with their equivalent unicode escaped form - + # \u0026, \u003e, and \u003c. The Unicode sequences \u2028 and \u2029 are also + # escaped as they are treated as newline characters in some JavaScript engines. + # These sequences have identical meaning as the original characters inside the + # context of a JSON string, so assuming the input is a valid and well-formed + # JSON value, the output will have equivalent meaning when parsed: + # + # json = JSON.generate({ name: "</script><script>alert('PWNED!!!')</script>"}) + # # => "{\"name\":\"</script><script>alert('PWNED!!!')</script>\"}" + # + # json_escape(json) + # # => "{\"name\":\"\\u003C/script\\u003E\\u003Cscript\\u003Ealert('PWNED!!!')\\u003C/script\\u003E\"}" + # + # JSON.parse(json) == JSON.parse(json_escape(json)) + # # => true + # + # The intended use case for this method is to escape JSON strings before including + # them inside a script tag to avoid XSS vulnerability: + # + # <script> + # var currentUser = <%= raw json_escape(current_user.to_json) %>; + # </script> + # + # It is necessary to +raw+ the result of +json_escape+, so that quotation marks + # don't get converted to <tt>"</tt> entities. +json_escape+ doesn't + # automatically flag the result as HTML safe, since the raw value is unsafe to + # use inside HTML attributes. + # + # If your JSON is being used downstream for insertion into the DOM, be aware of + # whether or not it is being inserted via +html()+. Most jQuery plugins do this. + # If that is the case, be sure to +html_escape+ or +sanitize+ any user-generated + # content returned by your JSON. + # + # If you need to output JSON elsewhere in your HTML, you can just do something + # like this, as any unsafe characters (including quotation marks) will be + # automatically escaped for you: + # + # <div data-user-info="<%= current_user.to_json %>">...</div> + # + # WARNING: this helper only works with valid JSON. Using this on non-JSON values + # will open up serious XSS vulnerabilities. For example, if you replace the + # +current_user.to_json+ in the example above with user input instead, the browser + # will happily eval() that string as JavaScript. + # + # The escaping performed in this method is identical to those performed in the + # Active Support JSON encoder when +ActiveSupport.escape_html_entities_in_json+ is + # set to true. Because this transformation is idempotent, this helper can be + # applied even if +ActiveSupport.escape_html_entities_in_json+ is already true. + # + # Therefore, when you are unsure if +ActiveSupport.escape_html_entities_in_json+ + # is enabled, or if you are unsure where your JSON string originated from, it + # is recommended that you always apply this helper (other libraries, such as the + # JSON gem, do not provide this kind of protection by default; also some gems + # might override +to_json+ to bypass Active Support's encoder). + def json_escape(s) + result = s.to_s.gsub(JSON_ESCAPE_REGEXP, JSON_ESCAPE) + s.html_safe? ? result.html_safe : result + end + + module_function :json_escape + end +end + +class Object + def html_safe? + false + end +end + +class Numeric + def html_safe? + true + end +end + +module ActiveSupport #:nodoc: + class SafeBuffer < String + UNSAFE_STRING_METHODS = %w( + capitalize chomp chop delete delete_prefix delete_suffix + downcase gsub lstrip next reverse rstrip slice squeeze strip + sub succ swapcase tr tr_s unicode_normalize upcase + ) + + alias_method :original_concat, :concat + private :original_concat + + # Raised when <tt>ActiveSupport::SafeBuffer#safe_concat</tt> is called on unsafe buffers. + class SafeConcatError < StandardError + def initialize + super "Could not concatenate to the buffer because it is not html safe." + end + end + + def [](*args) + if html_safe? + new_safe_buffer = super + + if new_safe_buffer + new_safe_buffer.instance_variable_set :@html_safe, true + end + + new_safe_buffer + else + to_str[*args] + end + end + + def safe_concat(value) + raise SafeConcatError unless html_safe? + original_concat(value) + end + + def initialize(str = "") + @html_safe = true + super + end + + def initialize_copy(other) + super + @html_safe = other.html_safe? + end + + def clone_empty + self[0, 0] + end + + def concat(value) + super(html_escape_interpolated_argument(value)) + end + alias << concat + + def insert(index, value) + super(index, html_escape_interpolated_argument(value)) + end + + def prepend(value) + super(html_escape_interpolated_argument(value)) + end + + def replace(value) + super(html_escape_interpolated_argument(value)) + end + + def []=(index, value) + super(index, html_escape_interpolated_argument(value)) + end + + def +(other) + dup.concat(other) + end + + def %(args) + case args + when Hash + escaped_args = Hash[args.map { |k, arg| [k, html_escape_interpolated_argument(arg)] }] + else + escaped_args = Array(args).map { |arg| html_escape_interpolated_argument(arg) } + end + + self.class.new(super(escaped_args)) + end + + def html_safe? + defined?(@html_safe) && @html_safe + end + + def to_s + self + end + + def to_param + to_str + end + + def encode_with(coder) + coder.represent_object nil, to_str + end + + UNSAFE_STRING_METHODS.each do |unsafe_method| + if unsafe_method.respond_to?(unsafe_method) + class_eval <<-EOT, __FILE__, __LINE__ + 1 + def #{unsafe_method}(*args, &block) # def capitalize(*args, &block) + to_str.#{unsafe_method}(*args, &block) # to_str.capitalize(*args, &block) + end # end + + def #{unsafe_method}!(*args) # def capitalize!(*args) + @html_safe = false # @html_safe = false + super # super + end # end + EOT + end + end + + private + + def html_escape_interpolated_argument(arg) + (!html_safe? || arg.html_safe?) ? arg : CGI.escapeHTML(arg.to_s) + end + end +end + +class String + # Marks a string as trusted safe. It will be inserted into HTML with no + # additional escaping performed. It is your responsibility to ensure that the + # string contains no malicious content. This method is equivalent to the + # +raw+ helper in views. It is recommended that you use +sanitize+ instead of + # this method. It should never be called on user input. + def html_safe + ActiveSupport::SafeBuffer.new(self) + end +end diff --git a/activesupport/lib/active_support/core_ext/string/starts_ends_with.rb b/activesupport/lib/active_support/core_ext/string/starts_ends_with.rb new file mode 100644 index 0000000000..919eb7a573 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/starts_ends_with.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class String + alias_method :starts_with?, :start_with? + alias_method :ends_with?, :end_with? +end diff --git a/activesupport/lib/active_support/core_ext/string/strip.rb b/activesupport/lib/active_support/core_ext/string/strip.rb new file mode 100644 index 0000000000..60e9952ee6 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/strip.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class String + # Strips indentation in heredocs. + # + # For example in + # + # if options[:usage] + # puts <<-USAGE.strip_heredoc + # This command does such and such. + # + # Supported options are: + # -h This message + # ... + # USAGE + # end + # + # the user would see the usage message aligned against the left margin. + # + # Technically, it looks for the least indented non-empty line + # in the whole string, and removes that amount of leading whitespace. + def strip_heredoc + gsub(/^#{scan(/^[ \t]*(?=\S)/).min}/, "").tap do |stripped| + stripped.freeze if frozen? + end + end +end diff --git a/activesupport/lib/active_support/core_ext/string/zones.rb b/activesupport/lib/active_support/core_ext/string/zones.rb new file mode 100644 index 0000000000..55dc231464 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/string/zones.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/conversions" +require "active_support/core_ext/time/zones" + +class String + # Converts String to a TimeWithZone in the current zone if Time.zone or Time.zone_default + # is set, otherwise converts String to a Time via String#to_time + def in_time_zone(zone = ::Time.zone) + if zone + ::Time.find_zone!(zone).parse(self) + else + to_time + end + end +end diff --git a/activesupport/lib/active_support/core_ext/time.rb b/activesupport/lib/active_support/core_ext/time.rb new file mode 100644 index 0000000000..c809def05f --- /dev/null +++ b/activesupport/lib/active_support/core_ext/time.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "active_support/core_ext/time/acts_like" +require "active_support/core_ext/time/calculations" +require "active_support/core_ext/time/compatibility" +require "active_support/core_ext/time/conversions" +require "active_support/core_ext/time/zones" diff --git a/activesupport/lib/active_support/core_ext/time/acts_like.rb b/activesupport/lib/active_support/core_ext/time/acts_like.rb new file mode 100644 index 0000000000..8572b49639 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/time/acts_like.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/acts_like" + +class Time + # Duck-types as a Time-like class. See Object#acts_like?. + def acts_like_time? + true + end +end diff --git a/activesupport/lib/active_support/core_ext/time/calculations.rb b/activesupport/lib/active_support/core_ext/time/calculations.rb new file mode 100644 index 0000000000..120768dec5 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/time/calculations.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +require "active_support/duration" +require "active_support/core_ext/time/conversions" +require "active_support/time_with_zone" +require "active_support/core_ext/time/zones" +require "active_support/core_ext/date_and_time/calculations" +require "active_support/core_ext/date/calculations" + +class Time + include DateAndTime::Calculations + + COMMON_YEAR_DAYS_IN_MONTH = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + + class << self + # Overriding case equality method so that it returns true for ActiveSupport::TimeWithZone instances + def ===(other) + super || (self == Time && other.is_a?(ActiveSupport::TimeWithZone)) + end + + # Returns the number of days in the given month. + # If no year is specified, it will use the current year. + def days_in_month(month, year = current.year) + if month == 2 && ::Date.gregorian_leap?(year) + 29 + else + COMMON_YEAR_DAYS_IN_MONTH[month] + end + end + + # Returns the number of days in the given year. + # If no year is specified, it will use the current year. + def days_in_year(year = current.year) + days_in_month(2, year) + 337 + end + + # Returns <tt>Time.zone.now</tt> when <tt>Time.zone</tt> or <tt>config.time_zone</tt> are set, otherwise just returns <tt>Time.now</tt>. + def current + ::Time.zone ? ::Time.zone.now : ::Time.now + end + + # Layers additional behavior on Time.at so that ActiveSupport::TimeWithZone and DateTime + # instances can be used when called with a single argument + def at_with_coercion(*args) + return at_without_coercion(*args) if args.size != 1 + + # Time.at can be called with a time or numerical value + time_or_number = args.first + + if time_or_number.is_a?(ActiveSupport::TimeWithZone) || time_or_number.is_a?(DateTime) + at_without_coercion(time_or_number.to_f).getlocal + else + at_without_coercion(time_or_number) + end + end + alias_method :at_without_coercion, :at + alias_method :at, :at_with_coercion + + # Creates a +Time+ instance from an RFC 3339 string. + # + # Time.rfc3339('1999-12-31T14:00:00-10:00') # => 2000-01-01 00:00:00 -1000 + # + # If the time or offset components are missing then an +ArgumentError+ will be raised. + # + # Time.rfc3339('1999-12-31') # => ArgumentError: invalid date + def rfc3339(str) + parts = Date._rfc3339(str) + + raise ArgumentError, "invalid date" if parts.empty? + + Time.new( + parts.fetch(:year), + parts.fetch(:mon), + parts.fetch(:mday), + parts.fetch(:hour), + parts.fetch(:min), + parts.fetch(:sec) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset) + ) + end + end + + # Returns the number of seconds since 00:00:00. + # + # Time.new(2012, 8, 29, 0, 0, 0).seconds_since_midnight # => 0.0 + # Time.new(2012, 8, 29, 12, 34, 56).seconds_since_midnight # => 45296.0 + # Time.new(2012, 8, 29, 23, 59, 59).seconds_since_midnight # => 86399.0 + def seconds_since_midnight + to_i - change(hour: 0).to_i + (usec / 1.0e+6) + end + + # Returns the number of seconds until 23:59:59. + # + # Time.new(2012, 8, 29, 0, 0, 0).seconds_until_end_of_day # => 86399 + # Time.new(2012, 8, 29, 12, 34, 56).seconds_until_end_of_day # => 41103 + # Time.new(2012, 8, 29, 23, 59, 59).seconds_until_end_of_day # => 0 + def seconds_until_end_of_day + end_of_day.to_i - to_i + end + + # Returns the fraction of a second as a +Rational+ + # + # Time.new(2012, 8, 29, 0, 0, 0.5).sec_fraction # => (1/2) + def sec_fraction + subsec + end + + # Returns a new Time where one or more of the elements have been changed according + # to the +options+ parameter. The time options (<tt>:hour</tt>, <tt>:min</tt>, + # <tt>:sec</tt>, <tt>:usec</tt>, <tt>:nsec</tt>) reset cascadingly, so if only + # the hour is passed, then minute, sec, usec and nsec is set to 0. If the hour + # and minute is passed, then sec, usec and nsec is set to 0. The +options+ parameter + # takes a hash with any of these keys: <tt>:year</tt>, <tt>:month</tt>, <tt>:day</tt>, + # <tt>:hour</tt>, <tt>:min</tt>, <tt>:sec</tt>, <tt>:usec</tt>, <tt>:nsec</tt>, + # <tt>:offset</tt>. Pass either <tt>:usec</tt> or <tt>:nsec</tt>, not both. + # + # Time.new(2012, 8, 29, 22, 35, 0).change(day: 1) # => Time.new(2012, 8, 1, 22, 35, 0) + # Time.new(2012, 8, 29, 22, 35, 0).change(year: 1981, day: 1) # => Time.new(1981, 8, 1, 22, 35, 0) + # Time.new(2012, 8, 29, 22, 35, 0).change(year: 1981, hour: 0) # => Time.new(1981, 8, 29, 0, 0, 0) + def change(options) + new_year = options.fetch(:year, year) + new_month = options.fetch(:month, month) + new_day = options.fetch(:day, day) + new_hour = options.fetch(:hour, hour) + new_min = options.fetch(:min, options[:hour] ? 0 : min) + new_sec = options.fetch(:sec, (options[:hour] || options[:min]) ? 0 : sec) + new_offset = options.fetch(:offset, nil) + + if new_nsec = options[:nsec] + raise ArgumentError, "Can't change both :nsec and :usec at the same time: #{options.inspect}" if options[:usec] + new_usec = Rational(new_nsec, 1000) + else + new_usec = options.fetch(:usec, (options[:hour] || options[:min] || options[:sec]) ? 0 : Rational(nsec, 1000)) + end + + raise ArgumentError, "argument out of range" if new_usec >= 1000000 + + new_sec += Rational(new_usec, 1000000) + + if new_offset + ::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, new_offset) + elsif utc? + ::Time.utc(new_year, new_month, new_day, new_hour, new_min, new_sec) + elsif zone + ::Time.local(new_year, new_month, new_day, new_hour, new_min, new_sec) + else + ::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, utc_offset) + end + end + + # Uses Date to provide precise Time calculations for years, months, and days + # according to the proleptic Gregorian calendar. The +options+ parameter + # takes a hash with any of these keys: <tt>:years</tt>, <tt>:months</tt>, + # <tt>:weeks</tt>, <tt>:days</tt>, <tt>:hours</tt>, <tt>:minutes</tt>, + # <tt>:seconds</tt>. + # + # Time.new(2015, 8, 1, 14, 35, 0).advance(seconds: 1) # => 2015-08-01 14:35:01 -0700 + # Time.new(2015, 8, 1, 14, 35, 0).advance(minutes: 1) # => 2015-08-01 14:36:00 -0700 + # Time.new(2015, 8, 1, 14, 35, 0).advance(hours: 1) # => 2015-08-01 15:35:00 -0700 + # Time.new(2015, 8, 1, 14, 35, 0).advance(days: 1) # => 2015-08-02 14:35:00 -0700 + # Time.new(2015, 8, 1, 14, 35, 0).advance(weeks: 1) # => 2015-08-08 14:35:00 -0700 + def advance(options) + unless options[:weeks].nil? + options[:weeks], partial_weeks = options[:weeks].divmod(1) + options[:days] = options.fetch(:days, 0) + 7 * partial_weeks + end + + unless options[:days].nil? + options[:days], partial_days = options[:days].divmod(1) + options[:hours] = options.fetch(:hours, 0) + 24 * partial_days + end + + d = to_date.advance(options) + d = d.gregorian if d.julian? + time_advanced_by_date = change(year: d.year, month: d.month, day: d.day) + seconds_to_advance = \ + options.fetch(:seconds, 0) + + options.fetch(:minutes, 0) * 60 + + options.fetch(:hours, 0) * 3600 + + if seconds_to_advance.zero? + time_advanced_by_date + else + time_advanced_by_date.since(seconds_to_advance) + end + end + + # Returns a new Time representing the time a number of seconds ago, this is basically a wrapper around the Numeric extension + def ago(seconds) + since(-seconds) + end + + # Returns a new Time representing the time a number of seconds since the instance time + def since(seconds) + self + seconds + rescue + to_datetime.since(seconds) + end + alias :in :since + + # Returns a new Time representing the start of the day (0:00) + def beginning_of_day + change(hour: 0) + end + alias :midnight :beginning_of_day + alias :at_midnight :beginning_of_day + alias :at_beginning_of_day :beginning_of_day + + # Returns a new Time representing the middle of the day (12:00) + def middle_of_day + change(hour: 12) + end + alias :midday :middle_of_day + alias :noon :middle_of_day + alias :at_midday :middle_of_day + alias :at_noon :middle_of_day + alias :at_middle_of_day :middle_of_day + + # Returns a new Time representing the end of the day, 23:59:59.999999 + def end_of_day + change( + hour: 23, + min: 59, + sec: 59, + usec: Rational(999999999, 1000) + ) + end + alias :at_end_of_day :end_of_day + + # Returns a new Time representing the start of the hour (x:00) + def beginning_of_hour + change(min: 0) + end + alias :at_beginning_of_hour :beginning_of_hour + + # Returns a new Time representing the end of the hour, x:59:59.999999 + def end_of_hour + change( + min: 59, + sec: 59, + usec: Rational(999999999, 1000) + ) + end + alias :at_end_of_hour :end_of_hour + + # Returns a new Time representing the start of the minute (x:xx:00) + def beginning_of_minute + change(sec: 0) + end + alias :at_beginning_of_minute :beginning_of_minute + + # Returns a new Time representing the end of the minute, x:xx:59.999999 + def end_of_minute + change( + sec: 59, + usec: Rational(999999999, 1000) + ) + end + alias :at_end_of_minute :end_of_minute + + def plus_with_duration(other) #:nodoc: + if ActiveSupport::Duration === other + other.since(self) + else + plus_without_duration(other) + end + end + alias_method :plus_without_duration, :+ + alias_method :+, :plus_with_duration + + def minus_with_duration(other) #:nodoc: + if ActiveSupport::Duration === other + other.until(self) + else + minus_without_duration(other) + end + end + alias_method :minus_without_duration, :- + alias_method :-, :minus_with_duration + + # Time#- can also be used to determine the number of seconds between two Time instances. + # We're layering on additional behavior so that ActiveSupport::TimeWithZone instances + # are coerced into values that Time#- will recognize + def minus_with_coercion(other) + other = other.comparable_time if other.respond_to?(:comparable_time) + other.is_a?(DateTime) ? to_f - other.to_f : minus_without_coercion(other) + end + alias_method :minus_without_coercion, :- + alias_method :-, :minus_with_coercion + + # Layers additional behavior on Time#<=> so that DateTime and ActiveSupport::TimeWithZone instances + # can be chronologically compared with a Time + def compare_with_coercion(other) + # we're avoiding Time#to_datetime and Time#to_time because they're expensive + if other.class == Time + compare_without_coercion(other) + elsif other.is_a?(Time) + compare_without_coercion(other.to_time) + else + to_datetime <=> other + end + end + alias_method :compare_without_coercion, :<=> + alias_method :<=>, :compare_with_coercion + + # Layers additional behavior on Time#eql? so that ActiveSupport::TimeWithZone instances + # can be eql? to an equivalent Time + def eql_with_coercion(other) + # if other is an ActiveSupport::TimeWithZone, coerce a Time instance from it so we can do eql? comparison + other = other.comparable_time if other.respond_to?(:comparable_time) + eql_without_coercion(other) + end + alias_method :eql_without_coercion, :eql? + alias_method :eql?, :eql_with_coercion +end diff --git a/activesupport/lib/active_support/core_ext/time/compatibility.rb b/activesupport/lib/active_support/core_ext/time/compatibility.rb new file mode 100644 index 0000000000..495e4f307b --- /dev/null +++ b/activesupport/lib/active_support/core_ext/time/compatibility.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "active_support/core_ext/date_and_time/compatibility" +require "active_support/core_ext/module/redefine_method" + +class Time + include DateAndTime::Compatibility + + silence_redefinition_of_method :to_time + + # Either return +self+ or the time in the local system timezone depending + # on the setting of +ActiveSupport.to_time_preserves_timezone+. + def to_time + preserve_timezone ? self : getlocal + end +end diff --git a/activesupport/lib/active_support/core_ext/time/conversions.rb b/activesupport/lib/active_support/core_ext/time/conversions.rb new file mode 100644 index 0000000000..345cb2832c --- /dev/null +++ b/activesupport/lib/active_support/core_ext/time/conversions.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "active_support/inflector/methods" +require "active_support/values/time_zone" + +class Time + DATE_FORMATS = { + db: "%Y-%m-%d %H:%M:%S", + number: "%Y%m%d%H%M%S", + nsec: "%Y%m%d%H%M%S%9N", + usec: "%Y%m%d%H%M%S%6N", + time: "%H:%M", + short: "%d %b %H:%M", + long: "%B %d, %Y %H:%M", + long_ordinal: lambda { |time| + day_format = ActiveSupport::Inflector.ordinalize(time.day) + time.strftime("%B #{day_format}, %Y %H:%M") + }, + rfc822: lambda { |time| + offset_format = time.formatted_offset(false) + time.strftime("%a, %d %b %Y %H:%M:%S #{offset_format}") + }, + iso8601: lambda { |time| time.iso8601 } + } + + # Converts to a formatted string. See DATE_FORMATS for built-in formats. + # + # This method is aliased to <tt>to_s</tt>. + # + # time = Time.now # => 2007-01-18 06:10:17 -06:00 + # + # time.to_formatted_s(:time) # => "06:10" + # time.to_s(:time) # => "06:10" + # + # time.to_formatted_s(:db) # => "2007-01-18 06:10:17" + # time.to_formatted_s(:number) # => "20070118061017" + # time.to_formatted_s(:short) # => "18 Jan 06:10" + # time.to_formatted_s(:long) # => "January 18, 2007 06:10" + # time.to_formatted_s(:long_ordinal) # => "January 18th, 2007 06:10" + # time.to_formatted_s(:rfc822) # => "Thu, 18 Jan 2007 06:10:17 -0600" + # time.to_formatted_s(:iso8601) # => "2007-01-18T06:10:17-06:00" + # + # == Adding your own time formats to +to_formatted_s+ + # You can add your own formats to the Time::DATE_FORMATS hash. + # Use the format name as the hash key and either a strftime string + # or Proc instance that takes a time argument as the value. + # + # # config/initializers/time_formats.rb + # Time::DATE_FORMATS[:month_and_year] = '%B %Y' + # Time::DATE_FORMATS[:short_ordinal] = ->(time) { time.strftime("%B #{time.day.ordinalize}") } + def to_formatted_s(format = :default) + if formatter = DATE_FORMATS[format] + formatter.respond_to?(:call) ? formatter.call(self).to_s : strftime(formatter) + else + to_default_s + end + end + alias_method :to_default_s, :to_s + alias_method :to_s, :to_formatted_s + + # Returns a formatted string of the offset from UTC, or an alternative + # string if the time zone is already UTC. + # + # Time.local(2000).formatted_offset # => "-06:00" + # Time.local(2000).formatted_offset(false) # => "-0600" + def formatted_offset(colon = true, alternate_utc_string = nil) + utc? && alternate_utc_string || ActiveSupport::TimeZone.seconds_to_utc_offset(utc_offset, colon) + end + + # Aliased to +xmlschema+ for compatibility with +DateTime+ + alias_method :rfc3339, :xmlschema +end diff --git a/activesupport/lib/active_support/core_ext/time/zones.rb b/activesupport/lib/active_support/core_ext/time/zones.rb new file mode 100644 index 0000000000..a5588fd488 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/time/zones.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "active_support/time_with_zone" +require "active_support/core_ext/time/acts_like" +require "active_support/core_ext/date_and_time/zones" + +class Time + include DateAndTime::Zones + class << self + attr_accessor :zone_default + + # Returns the TimeZone for the current request, if this has been set (via Time.zone=). + # If <tt>Time.zone</tt> has not been set for the current request, returns the TimeZone specified in <tt>config.time_zone</tt>. + def zone + Thread.current[:time_zone] || zone_default + end + + # Sets <tt>Time.zone</tt> to a TimeZone object for the current request/thread. + # + # This method accepts any of the following: + # + # * A Rails TimeZone object. + # * An identifier for a Rails TimeZone object (e.g., "Eastern Time (US & Canada)", <tt>-5.hours</tt>). + # * A TZInfo::Timezone object. + # * An identifier for a TZInfo::Timezone object (e.g., "America/New_York"). + # + # Here's an example of how you might set <tt>Time.zone</tt> on a per request basis and reset it when the request is done. + # <tt>current_user.time_zone</tt> just needs to return a string identifying the user's preferred time zone: + # + # class ApplicationController < ActionController::Base + # around_action :set_time_zone + # + # def set_time_zone + # if logged_in? + # Time.use_zone(current_user.time_zone) { yield } + # else + # yield + # end + # end + # end + def zone=(time_zone) + Thread.current[:time_zone] = find_zone!(time_zone) + end + + # Allows override of <tt>Time.zone</tt> locally inside supplied block; + # resets <tt>Time.zone</tt> to existing value when done. + # + # class ApplicationController < ActionController::Base + # around_action :set_time_zone + # + # private + # + # def set_time_zone + # Time.use_zone(current_user.timezone) { yield } + # end + # end + # + # NOTE: This won't affect any <tt>ActiveSupport::TimeWithZone</tt> + # objects that have already been created, e.g. any model timestamp + # attributes that have been read before the block will remain in + # the application's default timezone. + def use_zone(time_zone) + new_zone = find_zone!(time_zone) + begin + old_zone, ::Time.zone = ::Time.zone, new_zone + yield + ensure + ::Time.zone = old_zone + end + end + + # Returns a TimeZone instance matching the time zone provided. + # Accepts the time zone in any format supported by <tt>Time.zone=</tt>. + # Raises an +ArgumentError+ for invalid time zones. + # + # Time.find_zone! "America/New_York" # => #<ActiveSupport::TimeZone @name="America/New_York" ...> + # Time.find_zone! "EST" # => #<ActiveSupport::TimeZone @name="EST" ...> + # Time.find_zone! -5.hours # => #<ActiveSupport::TimeZone @name="Bogota" ...> + # Time.find_zone! nil # => nil + # Time.find_zone! false # => false + # Time.find_zone! "NOT-A-TIMEZONE" # => ArgumentError: Invalid Timezone: NOT-A-TIMEZONE + def find_zone!(time_zone) + if !time_zone || time_zone.is_a?(ActiveSupport::TimeZone) + time_zone + else + # Look up the timezone based on the identifier (unless we've been + # passed a TZInfo::Timezone) + unless time_zone.respond_to?(:period_for_local) + time_zone = ActiveSupport::TimeZone[time_zone] || TZInfo::Timezone.get(time_zone) + end + + # Return if a TimeZone instance, or wrap in a TimeZone instance if a TZInfo::Timezone + if time_zone.is_a?(ActiveSupport::TimeZone) + time_zone + else + ActiveSupport::TimeZone.create(time_zone.name, nil, time_zone) + end + end + rescue TZInfo::InvalidTimezoneIdentifier + raise ArgumentError, "Invalid Timezone: #{time_zone}" + end + + # Returns a TimeZone instance matching the time zone provided. + # Accepts the time zone in any format supported by <tt>Time.zone=</tt>. + # Returns +nil+ for invalid time zones. + # + # Time.find_zone "America/New_York" # => #<ActiveSupport::TimeZone @name="America/New_York" ...> + # Time.find_zone "NOT-A-TIMEZONE" # => nil + def find_zone(time_zone) + find_zone!(time_zone) rescue nil + end + end +end diff --git a/activesupport/lib/active_support/core_ext/uri.rb b/activesupport/lib/active_support/core_ext/uri.rb new file mode 100644 index 0000000000..cdd81ae562 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/uri.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "uri" + +if RUBY_VERSION < "2.6.0" + require "active_support/core_ext/module/redefine_method" + URI::Parser.class_eval do + silence_redefinition_of_method :unescape + def unescape(str, escaped = /%[a-fA-F\d]{2}/) + # TODO: Are we actually sure that ASCII == UTF-8? + # YK: My initial experiments say yes, but let's be sure please + enc = str.encoding + enc = Encoding::UTF_8 if enc == Encoding::US_ASCII + str.dup.force_encoding(Encoding::ASCII_8BIT).gsub(escaped) { |match| [match[1, 2].hex].pack("C") }.force_encoding(enc) + end + end +end + +module URI + class << self + def parser + @parser ||= URI::Parser.new + end + end +end diff --git a/activesupport/lib/active_support/current_attributes.rb b/activesupport/lib/active_support/current_attributes.rb new file mode 100644 index 0000000000..3145ff87a1 --- /dev/null +++ b/activesupport/lib/active_support/current_attributes.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require "active_support/callbacks" + +module ActiveSupport + # Abstract super class that provides a thread-isolated attributes singleton, which resets automatically + # before and after each 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_by(id: cookies.encrypted[: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 + current_instances[name] ||= new + 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_value(&:reset) + end + + def clear_all # :nodoc: + reset_all + current_instances.clear + 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/dependencies.rb b/activesupport/lib/active_support/dependencies.rb new file mode 100644 index 0000000000..d5d00b5e6e --- /dev/null +++ b/activesupport/lib/active_support/dependencies.rb @@ -0,0 +1,770 @@ +# frozen_string_literal: true + +require "set" +require "thread" +require "concurrent/map" +require "pathname" +require "active_support/core_ext/module/aliasing" +require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/module/introspection" +require "active_support/core_ext/module/anonymous" +require "active_support/core_ext/object/blank" +require "active_support/core_ext/kernel/reporting" +require "active_support/core_ext/load_error" +require "active_support/core_ext/name_error" +require "active_support/core_ext/string/starts_ends_with" +require "active_support/dependencies/interlock" +require "active_support/inflector" + +module ActiveSupport #:nodoc: + module Dependencies #:nodoc: + extend self + + mattr_accessor :interlock, default: Interlock.new + + # :doc: + + # Execute the supplied block without interference from any + # concurrent loads. + def self.run_interlock + Dependencies.interlock.running { yield } + end + + # Execute the supplied block while holding an exclusive lock, + # preventing any other thread from being inside a #run_interlock + # block at the same time. + def self.load_interlock + Dependencies.interlock.loading { yield } + end + + # Execute the supplied block while holding an exclusive lock, + # preventing any other thread from being inside a #run_interlock + # block at the same time. + def self.unload_interlock + Dependencies.interlock.unloading { yield } + end + + # :nodoc: + + # Should we turn on Ruby warnings on the first load of dependent files? + mattr_accessor :warnings_on_first_load, default: false + + # All files ever loaded. + mattr_accessor :history, default: Set.new + + # All files currently loaded. + mattr_accessor :loaded, default: Set.new + + # Stack of files being loaded. + mattr_accessor :loading, default: [] + + # Should we load files or require them? + mattr_accessor :mechanism, default: ENV["NO_RELOAD"] ? :require : :load + + # The set of directories from which we may automatically load files. Files + # under these directories will be reloaded on each request in development mode, + # unless the directory also appears in autoload_once_paths. + mattr_accessor :autoload_paths, default: [] + + # The set of directories from which automatically loaded constants are loaded + # only once. All directories in this set must also be present in +autoload_paths+. + mattr_accessor :autoload_once_paths, default: [] + + # An array of qualified constant names that have been loaded. Adding a name + # to this array will cause it to be unloaded the next time Dependencies are + # cleared. + mattr_accessor :autoloaded_constants, default: [] + + # An array of constant names that need to be unloaded on every request. Used + # to allow arbitrary constants to be marked for unloading. + mattr_accessor :explicitly_unloadable_constants, default: [] + + # The logger used when tracing autoloads. + mattr_accessor :logger + + # If true, trace autoloads with +logger.debug+. + mattr_accessor :verbose, default: false + + # The WatchStack keeps a stack of the modules being watched as files are + # loaded. If a file in the process of being loaded (parent.rb) triggers the + # load of another file (child.rb) the stack will ensure that child.rb + # handles the new constants. + # + # If child.rb is being autoloaded, its constants will be added to + # autoloaded_constants. If it was being required, they will be discarded. + # + # This is handled by walking back up the watch stack and adding the constants + # found by child.rb to the list of original constants in parent.rb. + class WatchStack + include Enumerable + + # @watching is a stack of lists of constants being watched. For instance, + # if parent.rb is autoloaded, the stack will look like [[Object]]. If + # parent.rb then requires namespace/child.rb, the stack will look like + # [[Object], [Namespace]]. + + attr_reader :watching + + def initialize + @watching = [] + @stack = Hash.new { |h, k| h[k] = [] } + end + + def each(&block) + @stack.each(&block) + end + + def watching? + !@watching.empty? + end + + # Returns a list of new constants found since the last call to + # <tt>watch_namespaces</tt>. + def new_constants + constants = [] + + # Grab the list of namespaces that we're looking for new constants under + @watching.last.each do |namespace| + # Retrieve the constants that were present under the namespace when watch_namespaces + # was originally called + original_constants = @stack[namespace].last + + mod = Inflector.constantize(namespace) if Dependencies.qualified_const_defined?(namespace) + next unless mod.is_a?(Module) + + # Get a list of the constants that were added + new_constants = mod.constants(false) - original_constants + + # @stack[namespace] returns an Array of the constants that are being evaluated + # for that namespace. For instance, if parent.rb requires child.rb, the first + # element of @stack[Object] will be an Array of the constants that were present + # before parent.rb was required. The second element will be an Array of the + # constants that were present before child.rb was required. + @stack[namespace].each do |namespace_constants| + namespace_constants.concat(new_constants) + end + + # Normalize the list of new constants, and add them to the list we will return + new_constants.each do |suffix| + constants << ([namespace, suffix] - ["Object"]).join("::") + end + end + constants + ensure + # A call to new_constants is always called after a call to watch_namespaces + pop_modules(@watching.pop) + end + + # Add a set of modules to the watch stack, remembering the initial + # constants. + def watch_namespaces(namespaces) + @watching << namespaces.map do |namespace| + module_name = Dependencies.to_constant_name(namespace) + original_constants = Dependencies.qualified_const_defined?(module_name) ? + Inflector.constantize(module_name).constants(false) : [] + + @stack[module_name] << original_constants + module_name + end + end + + private + def pop_modules(modules) + modules.each { |mod| @stack[mod].pop } + end + end + + # An internal stack used to record which constants are loaded by any block. + mattr_accessor :constant_watch_stack, default: WatchStack.new + + # Module includes this module. + module ModuleConstMissing #:nodoc: + def self.append_features(base) + base.class_eval do + # Emulate #exclude via an ivar + return if defined?(@_const_missing) && @_const_missing + @_const_missing = instance_method(:const_missing) + remove_method(:const_missing) + end + super + end + + def self.exclude_from(base) + base.class_eval do + define_method :const_missing, @_const_missing + @_const_missing = nil + end + end + + def const_missing(const_name) + from_mod = anonymous? ? guess_for_anonymous(const_name) : self + Dependencies.load_missing_constant(from_mod, const_name) + end + + # We assume that the name of the module reflects the nesting + # (unless it can be proven that is not the case) and the path to the file + # that defines the constant. Anonymous modules cannot follow these + # conventions and therefore we assume that the user wants to refer to a + # top-level constant. + def guess_for_anonymous(const_name) + if Object.const_defined?(const_name) + raise NameError.new "#{const_name} cannot be autoloaded from an anonymous class or module", const_name + else + Object + end + end + + def unloadable(const_desc = self) + super(const_desc) + end + end + + # Object includes this module. + module Loadable #:nodoc: + def self.exclude_from(base) + base.class_eval do + define_method(:load, Kernel.instance_method(:load)) + private :load + end + end + + def require_or_load(file_name) + Dependencies.require_or_load(file_name) + end + + # :doc: + + # Interprets a file using <tt>mechanism</tt> and marks its defined + # constants as autoloaded. <tt>file_name</tt> can be either a string or + # respond to <tt>to_path</tt>. + # + # Use this method in code that absolutely needs a certain constant to be + # defined at that point. A typical use case is to make constant name + # resolution deterministic for constants with the same relative name in + # different namespaces whose evaluation would depend on load order + # otherwise. + def require_dependency(file_name, message = "No such file to load -- %s.rb") + file_name = file_name.to_path if file_name.respond_to?(:to_path) + unless file_name.is_a?(String) + raise ArgumentError, "the file name must either be a String or implement #to_path -- you passed #{file_name.inspect}" + end + + Dependencies.depend_on(file_name, message) + end + + # :nodoc: + + def load_dependency(file) + if Dependencies.load? && Dependencies.constant_watch_stack.watching? + descs = Dependencies.constant_watch_stack.watching.flatten.uniq + + Dependencies.new_constants_in(*descs) { yield } + else + yield + end + rescue Exception => exception # errors from loading file + exception.blame_file! file if exception.respond_to? :blame_file! + raise + end + + # Mark the given constant as unloadable. Unloadable constants are removed + # each time dependencies are cleared. + # + # Note that marking a constant for unloading need only be done once. Setup + # or init scripts may list each unloadable constant that may need unloading; + # each constant will be removed for every subsequent clear, as opposed to + # for the first clear. + # + # The provided constant descriptor may be a (non-anonymous) module or class, + # or a qualified constant name as a string or symbol. + # + # Returns +true+ if the constant was not previously marked for unloading, + # +false+ otherwise. + def unloadable(const_desc) + Dependencies.mark_for_unload const_desc + end + + private + + def load(file, wrap = false) + result = false + load_dependency(file) { result = super } + result + end + + def require(file) + result = false + load_dependency(file) { result = super } + result + end + end + + # Exception file-blaming. + module Blamable #:nodoc: + def blame_file!(file) + (@blamed_files ||= []).unshift file + end + + def blamed_files + @blamed_files ||= [] + end + + def describe_blame + return nil if blamed_files.empty? + "This error occurred while loading the following files:\n #{blamed_files.join "\n "}" + end + + def copy_blame!(exc) + @blamed_files = exc.blamed_files.clone + self + end + end + + def hook! + Object.class_eval { include Loadable } + Module.class_eval { include ModuleConstMissing } + Exception.class_eval { include Blamable } + end + + def unhook! + ModuleConstMissing.exclude_from(Module) + Loadable.exclude_from(Object) + end + + def load? + mechanism == :load + end + + def depend_on(file_name, message = "No such file to load -- %s.rb") + path = search_for_file(file_name) + require_or_load(path || file_name) + rescue LoadError => load_error + if file_name = load_error.message[/ -- (.*?)(\.rb)?$/, 1] + load_error.message.replace(message % file_name) + load_error.copy_blame!(load_error) + end + raise + end + + def clear + Dependencies.unload_interlock do + loaded.clear + loading.clear + remove_unloadable_constants! + end + end + + def require_or_load(file_name, const_path = nil) + file_name = file_name.chomp(".rb") + expanded = File.expand_path(file_name) + return if loaded.include?(expanded) + + Dependencies.load_interlock do + # Maybe it got loaded while we were waiting for our lock: + return if loaded.include?(expanded) + + # Record that we've seen this file *before* loading it to avoid an + # infinite loop with mutual dependencies. + loaded << expanded + loading << expanded + + begin + if load? + # Enable warnings if this file has not been loaded before and + # warnings_on_first_load is set. + load_args = ["#{file_name}.rb"] + load_args << const_path unless const_path.nil? + + if !warnings_on_first_load || history.include?(expanded) + result = load_file(*load_args) + else + enable_warnings { result = load_file(*load_args) } + end + else + result = require file_name + end + rescue Exception + loaded.delete expanded + raise + ensure + loading.pop + end + + # Record history *after* loading so first load gets warnings. + history << expanded + result + end + end + + # Is the provided constant path defined? + def qualified_const_defined?(path) + Object.const_defined?(path, false) + end + + # Given +path+, a filesystem path to a ruby file, return an array of + # constant paths which would cause Dependencies to attempt to load this + # file. + def loadable_constants_for_path(path, bases = autoload_paths) + path = path.chomp(".rb") + expanded_path = File.expand_path(path) + paths = [] + + bases.each do |root| + expanded_root = File.expand_path(root) + next unless expanded_path.start_with?(expanded_root) + + root_size = expanded_root.size + next if expanded_path[root_size] != ?/ + + nesting = expanded_path[(root_size + 1)..-1] + paths << nesting.camelize unless nesting.blank? + end + + paths.uniq! + paths + end + + # Search for a file in autoload_paths matching the provided suffix. + def search_for_file(path_suffix) + path_suffix += ".rb" unless path_suffix.ends_with?(".rb") + + autoload_paths.each do |root| + path = File.join(root, path_suffix) + return path if File.file? path + end + nil # Gee, I sure wish we had first_match ;-) + end + + # Does the provided path_suffix correspond to an autoloadable module? + # Instead of returning a boolean, the autoload base for this module is + # returned. + def autoloadable_module?(path_suffix) + autoload_paths.each do |load_path| + return load_path if File.directory? File.join(load_path, path_suffix) + end + nil + end + + def load_once_path?(path) + # to_s works around a ruby issue where String#starts_with?(Pathname) + # will raise a TypeError: no implicit conversion of Pathname into String + autoload_once_paths.any? { |base| path.starts_with? base.to_s } + end + + # Attempt to autoload the provided module name by searching for a directory + # matching the expected path suffix. If found, the module is created and + # assigned to +into+'s constants with the name +const_name+. Provided that + # the directory was loaded from a reloadable base path, it is added to the + # set of constants that are to be unloaded. + def autoload_module!(into, const_name, qualified_name, path_suffix) + return nil unless base_path = autoloadable_module?(path_suffix) + mod = Module.new + into.const_set const_name, mod + log("constant #{qualified_name} autoloaded (module autovivified from #{File.join(base_path, path_suffix)})") + autoloaded_constants << qualified_name unless autoload_once_paths.include?(base_path) + autoloaded_constants.uniq! + mod + end + + # Load the file at the provided path. +const_paths+ is a set of qualified + # constant names. When loading the file, Dependencies will watch for the + # addition of these constants. Each that is defined will be marked as + # autoloaded, and will be removed when Dependencies.clear is next called. + # + # If the second parameter is left off, then Dependencies will construct a + # set of names that the file at +path+ may define. See + # +loadable_constants_for_path+ for more details. + def load_file(path, const_paths = loadable_constants_for_path(path)) + const_paths = [const_paths].compact unless const_paths.is_a? Array + parent_paths = const_paths.collect { |const_path| const_path[/.*(?=::)/] || ::Object } + + result = nil + newly_defined_paths = new_constants_in(*parent_paths) do + result = Kernel.load path + end + + autoloaded_constants.concat newly_defined_paths unless load_once_path?(path) + autoloaded_constants.uniq! + result + end + + # Returns the constant path for the provided parent and constant name. + def qualified_name_for(mod, name) + mod_name = to_constant_name mod + mod_name == "Object" ? name.to_s : "#{mod_name}::#{name}" + end + + # Load the constant named +const_name+ which is missing from +from_mod+. If + # it is not possible to load the constant into from_mod, try its parent + # module using +const_missing+. + def load_missing_constant(from_mod, const_name) + unless qualified_const_defined?(from_mod.name) && Inflector.constantize(from_mod.name).equal?(from_mod) + raise ArgumentError, "A copy of #{from_mod} has been removed from the module tree but is still active!" + end + + qualified_name = qualified_name_for(from_mod, const_name) + path_suffix = qualified_name.underscore + + file_path = search_for_file(path_suffix) + + if file_path + expanded = File.expand_path(file_path) + expanded.sub!(/\.rb\z/, "") + + if loading.include?(expanded) + raise "Circular dependency detected while autoloading constant #{qualified_name}" + else + require_or_load(expanded, qualified_name) + + if from_mod.const_defined?(const_name, false) + log("constant #{qualified_name} autoloaded from #{expanded}.rb") + return from_mod.const_get(const_name) + else + raise LoadError, "Unable to autoload constant #{qualified_name}, expected #{file_path} to define it" + end + end + elsif mod = autoload_module!(from_mod, const_name, qualified_name, path_suffix) + return mod + elsif (parent = from_mod.module_parent) && parent != from_mod && + ! from_mod.module_parents.any? { |p| p.const_defined?(const_name, false) } + # If our parents do not have a constant named +const_name+ then we are free + # to attempt to load upwards. If they do have such a constant, then this + # const_missing must be due to from_mod::const_name, which should not + # return constants from from_mod's parents. + begin + # Since Ruby does not pass the nesting at the point the unknown + # constant triggered the callback we cannot fully emulate constant + # name lookup and need to make a trade-off: we are going to assume + # that the nesting in the body of Foo::Bar is [Foo::Bar, Foo] even + # though it might not be. Counterexamples are + # + # class Foo::Bar + # Module.nesting # => [Foo::Bar] + # end + # + # or + # + # module M::N + # module S::T + # Module.nesting # => [S::T, M::N] + # end + # end + # + # for example. + return parent.const_missing(const_name) + rescue NameError => e + raise unless e.missing_name? qualified_name_for(parent, const_name) + end + end + + name_error = NameError.new("uninitialized constant #{qualified_name}", const_name) + name_error.set_backtrace(caller.reject { |l| l.starts_with? __FILE__ }) + raise name_error + end + + # Remove the constants that have been autoloaded, and those that have been + # marked for unloading. Before each constant is removed a callback is sent + # to its class/module if it implements +before_remove_const+. + # + # The callback implementation should be restricted to cleaning up caches, etc. + # as the environment will be in an inconsistent state, e.g. other constants + # may have already been unloaded and not accessible. + def remove_unloadable_constants! + log("removing unloadable constants") + autoloaded_constants.each { |const| remove_constant const } + autoloaded_constants.clear + Reference.clear! + explicitly_unloadable_constants.each { |const| remove_constant const } + end + + class ClassCache + def initialize + @store = Concurrent::Map.new + end + + def empty? + @store.empty? + end + + def key?(key) + @store.key?(key) + end + + def get(key) + key = key.name if key.respond_to?(:name) + @store[key] ||= Inflector.constantize(key) + end + alias :[] :get + + def safe_get(key) + key = key.name if key.respond_to?(:name) + @store[key] ||= Inflector.safe_constantize(key) + end + + def store(klass) + return self unless klass.respond_to?(:name) + raise(ArgumentError, "anonymous classes cannot be cached") if klass.name.empty? + @store[klass.name] = klass + self + end + + def clear! + @store.clear + end + end + + Reference = ClassCache.new + + # Store a reference to a class +klass+. + def reference(klass) + Reference.store klass + end + + # Get the reference for class named +name+. + # Raises an exception if referenced class does not exist. + def constantize(name) + Reference.get(name) + end + + # Get the reference for class named +name+ if one exists. + # Otherwise returns +nil+. + def safe_constantize(name) + Reference.safe_get(name) + end + + # Determine if the given constant has been automatically loaded. + def autoloaded?(desc) + return false if desc.is_a?(Module) && desc.anonymous? + name = to_constant_name desc + return false unless qualified_const_defined?(name) + autoloaded_constants.include?(name) + end + + # Will the provided constant descriptor be unloaded? + def will_unload?(const_desc) + autoloaded?(const_desc) || + explicitly_unloadable_constants.include?(to_constant_name(const_desc)) + end + + # Mark the provided constant name for unloading. This constant will be + # unloaded on each request, not just the next one. + def mark_for_unload(const_desc) + name = to_constant_name const_desc + if explicitly_unloadable_constants.include? name + false + else + explicitly_unloadable_constants << name + true + end + end + + # Run the provided block and detect the new constants that were loaded during + # its execution. Constants may only be regarded as 'new' once -- so if the + # block calls +new_constants_in+ again, then the constants defined within the + # inner call will not be reported in this one. + # + # If the provided block does not run to completion, and instead raises an + # exception, any new constants are regarded as being only partially defined + # and will be removed immediately. + def new_constants_in(*descs) + constant_watch_stack.watch_namespaces(descs) + success = false + + begin + yield # Now yield to the code that is to define new constants. + success = true + ensure + new_constants = constant_watch_stack.new_constants + + return new_constants if success + + # Remove partially loaded constants. + new_constants.each { |c| remove_constant(c) } + end + end + + # Convert the provided const desc to a qualified constant name (as a string). + # A module, class, symbol, or string may be provided. + def to_constant_name(desc) #:nodoc: + case desc + when String then desc.sub(/^::/, "") + when Symbol then desc.to_s + when Module + desc.name || + raise(ArgumentError, "Anonymous modules have no name to be referenced by") + else raise TypeError, "Not a valid constant descriptor: #{desc.inspect}" + end + end + + def remove_constant(const) #:nodoc: + # Normalize ::Foo, ::Object::Foo, Object::Foo, Object::Object::Foo, etc. as Foo. + normalized = const.to_s.sub(/\A::/, "") + normalized.sub!(/\A(Object::)+/, "") + + constants = normalized.split("::") + to_remove = constants.pop + + # Remove the file path from the loaded list. + file_path = search_for_file(const.underscore) + if file_path + expanded = File.expand_path(file_path) + expanded.sub!(/\.rb\z/, "") + loaded.delete(expanded) + end + + if constants.empty? + parent = Object + else + # This method is robust to non-reachable constants. + # + # Non-reachable constants may be passed if some of the parents were + # autoloaded and already removed. It is easier to do a sanity check + # here than require the caller to be clever. We check the parent + # rather than the very const argument because we do not want to + # trigger Kernel#autoloads, see the comment below. + parent_name = constants.join("::") + return unless qualified_const_defined?(parent_name) + parent = constantize(parent_name) + end + + # In an autoloaded user.rb like this + # + # autoload :Foo, 'foo' + # + # class User < ActiveRecord::Base + # end + # + # we correctly register "Foo" as being autoloaded. But if the app does + # not use the "Foo" constant we need to be careful not to trigger + # loading "foo.rb" ourselves. While #const_defined? and #const_get? do + # require the file, #autoload? and #remove_const don't. + # + # We are going to remove the constant nonetheless ---which exists as + # far as Ruby is concerned--- because if the user removes the macro + # call from a class or module that were not autoloaded, as in the + # example above with Object, accessing to that constant must err. + unless parent.autoload?(to_remove) + begin + constantized = parent.const_get(to_remove, false) + rescue NameError + # The constant is no longer reachable, just skip it. + return + else + constantized.before_remove_const if constantized.respond_to?(:before_remove_const) + end + end + + begin + parent.instance_eval { remove_const to_remove } + rescue NameError + # The constant is no longer reachable, just skip it. + end + end + + def log(message) + logger.debug("autoloading: #{message}") if logger && verbose + end + end +end + +ActiveSupport::Dependencies.hook! diff --git a/activesupport/lib/active_support/dependencies/autoload.rb b/activesupport/lib/active_support/dependencies/autoload.rb new file mode 100644 index 0000000000..1cee85d98f --- /dev/null +++ b/activesupport/lib/active_support/dependencies/autoload.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "active_support/inflector/methods" + +module ActiveSupport + # Autoload and eager load conveniences for your library. + # + # This module allows you to define autoloads based on + # Rails conventions (i.e. no need to define the path + # it is automatically guessed based on the filename) + # and also define a set of constants that needs to be + # eager loaded: + # + # module MyLib + # extend ActiveSupport::Autoload + # + # autoload :Model + # + # eager_autoload do + # autoload :Cache + # end + # end + # + # Then your library can be eager loaded by simply calling: + # + # MyLib.eager_load! + module Autoload + def self.extended(base) # :nodoc: + base.class_eval do + @_autoloads = {} + @_under_path = nil + @_at_path = nil + @_eager_autoload = false + end + end + + def autoload(const_name, path = @_at_path) + unless path + full = [name, @_under_path, const_name.to_s].compact.join("::") + path = Inflector.underscore(full) + end + + if @_eager_autoload + @_autoloads[const_name] = path + end + + super const_name, path + end + + def autoload_under(path) + @_under_path, old_path = path, @_under_path + yield + ensure + @_under_path = old_path + end + + def autoload_at(path) + @_at_path, old_path = path, @_at_path + yield + ensure + @_at_path = old_path + end + + def eager_autoload + old_eager, @_eager_autoload = @_eager_autoload, true + yield + ensure + @_eager_autoload = old_eager + end + + def eager_load! + @_autoloads.each_value { |file| require file } + end + + def autoloads + @_autoloads + end + end +end diff --git a/activesupport/lib/active_support/dependencies/interlock.rb b/activesupport/lib/active_support/dependencies/interlock.rb new file mode 100644 index 0000000000..948be75638 --- /dev/null +++ b/activesupport/lib/active_support/dependencies/interlock.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "active_support/concurrency/share_lock" + +module ActiveSupport #:nodoc: + module Dependencies #:nodoc: + class Interlock + def initialize # :nodoc: + @lock = ActiveSupport::Concurrency::ShareLock.new + end + + def loading + @lock.exclusive(purpose: :load, compatible: [:load], after_compatible: [:load]) do + yield + end + end + + def unloading + @lock.exclusive(purpose: :unload, compatible: [:load, :unload], after_compatible: [:load, :unload]) do + yield + end + end + + def start_unloading + @lock.start_exclusive(purpose: :unload, compatible: [:load, :unload]) + end + + def done_unloading + @lock.stop_exclusive(compatible: [:load, :unload]) + end + + def start_running + @lock.start_sharing + end + + def done_running + @lock.stop_sharing + end + + def running + @lock.sharing do + yield + end + end + + def permit_concurrent_loads + @lock.yield_shares(compatible: [:load]) do + yield + end + end + + def raw_state(&block) # :nodoc: + @lock.raw_state(&block) + end + end + end +end diff --git a/activesupport/lib/active_support/deprecation.rb b/activesupport/lib/active_support/deprecation.rb new file mode 100644 index 0000000000..7271ab565b --- /dev/null +++ b/activesupport/lib/active_support/deprecation.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "singleton" + +module ActiveSupport + # \Deprecation specifies the API used by Rails to deprecate methods, instance + # variables, objects and constants. + class Deprecation + # active_support.rb sets an autoload for ActiveSupport::Deprecation. + # + # If these requires were at the top of the file the constant would not be + # defined by the time their files were loaded. Since some of them reopen + # ActiveSupport::Deprecation its autoload would be triggered, resulting in + # a circular require warning for active_support/deprecation.rb. + # + # So, we define the constant first, and load dependencies later. + require "active_support/deprecation/instance_delegator" + require "active_support/deprecation/behaviors" + require "active_support/deprecation/reporting" + require "active_support/deprecation/constant_accessor" + require "active_support/deprecation/method_wrappers" + require "active_support/deprecation/proxy_wrappers" + require "active_support/core_ext/module/deprecation" + + include Singleton + include InstanceDelegator + include Behavior + include Reporting + include MethodWrapper + + # The version number in which the deprecated behavior will be removed, by default. + attr_accessor :deprecation_horizon + + # It accepts two parameters on initialization. The first is a version of library + # and the second is a library name. + # + # ActiveSupport::Deprecation.new('2.0', 'MyLibrary') + def initialize(deprecation_horizon = "6.1", gem_name = "Rails") + self.gem_name = gem_name + self.deprecation_horizon = deprecation_horizon + # By default, warnings are not silenced and debugging is off. + self.silenced = false + self.debug = false + end + end +end diff --git a/activesupport/lib/active_support/deprecation/behaviors.rb b/activesupport/lib/active_support/deprecation/behaviors.rb new file mode 100644 index 0000000000..725667d139 --- /dev/null +++ b/activesupport/lib/active_support/deprecation/behaviors.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "active_support/notifications" + +module ActiveSupport + # Raised when <tt>ActiveSupport::Deprecation::Behavior#behavior</tt> is set with <tt>:raise</tt>. + # You would set <tt>:raise</tt>, as a behavior to raise errors and proactively report exceptions from deprecations. + class DeprecationException < StandardError + end + + class Deprecation + # Default warning behaviors per Rails.env. + DEFAULT_BEHAVIORS = { + raise: ->(message, callstack, deprecation_horizon, gem_name) { + e = DeprecationException.new(message) + e.set_backtrace(callstack.map(&:to_s)) + raise e + }, + + stderr: ->(message, callstack, deprecation_horizon, gem_name) { + $stderr.puts(message) + $stderr.puts callstack.join("\n ") if debug + }, + + log: ->(message, callstack, deprecation_horizon, gem_name) { + logger = + if defined?(Rails.logger) && Rails.logger + Rails.logger + else + require "active_support/logger" + ActiveSupport::Logger.new($stderr) + end + logger.warn message + logger.debug callstack.join("\n ") if debug + }, + + notify: ->(message, callstack, deprecation_horizon, gem_name) { + notification_name = "deprecation.#{gem_name.underscore.tr('/', '_')}" + ActiveSupport::Notifications.instrument(notification_name, + message: message, + callstack: callstack, + gem_name: gem_name, + deprecation_horizon: deprecation_horizon) + }, + + silence: ->(message, callstack, deprecation_horizon, gem_name) { }, + } + + # Behavior module allows to determine how to display deprecation messages. + # You can create a custom behavior or set any from the +DEFAULT_BEHAVIORS+ + # constant. Available behaviors are: + # + # [+raise+] Raise <tt>ActiveSupport::DeprecationException</tt>. + # [+stderr+] Log all deprecation warnings to +$stderr+. + # [+log+] Log all deprecation warnings to +Rails.logger+. + # [+notify+] Use +ActiveSupport::Notifications+ to notify +deprecation.rails+. + # [+silence+] Do nothing. + # + # Setting behaviors only affects deprecations that happen after boot time. + # For more information you can read the documentation of the +behavior=+ method. + module Behavior + # Whether to print a backtrace along with the warning. + attr_accessor :debug + + # Returns the current behavior or if one isn't set, defaults to +:stderr+. + def behavior + @behavior ||= [DEFAULT_BEHAVIORS[:stderr]] + end + + # Sets the behavior to the specified value. Can be a single value, array, + # or an object that responds to +call+. + # + # Available behaviors: + # + # [+raise+] Raise <tt>ActiveSupport::DeprecationException</tt>. + # [+stderr+] Log all deprecation warnings to +$stderr+. + # [+log+] Log all deprecation warnings to +Rails.logger+. + # [+notify+] Use +ActiveSupport::Notifications+ to notify +deprecation.rails+. + # [+silence+] Do nothing. + # + # Setting behaviors only affects deprecations that happen after boot time. + # Deprecation warnings raised by gems are not affected by this setting + # because they happen before Rails boots up. + # + # ActiveSupport::Deprecation.behavior = :stderr + # ActiveSupport::Deprecation.behavior = [:stderr, :log] + # ActiveSupport::Deprecation.behavior = MyCustomHandler + # ActiveSupport::Deprecation.behavior = ->(message, callstack, deprecation_horizon, gem_name) { + # # custom stuff + # } + def behavior=(behavior) + @behavior = Array(behavior).map { |b| DEFAULT_BEHAVIORS[b] || arity_coerce(b) } + end + + private + def arity_coerce(behavior) + unless behavior.respond_to?(:call) + raise ArgumentError, "#{behavior.inspect} is not a valid deprecation behavior." + end + + if behavior.arity == 4 || behavior.arity == -1 + behavior + else + -> message, callstack, _, _ { behavior.call(message, callstack) } + end + end + end + end +end diff --git a/activesupport/lib/active_support/deprecation/constant_accessor.rb b/activesupport/lib/active_support/deprecation/constant_accessor.rb new file mode 100644 index 0000000000..1ed0015812 --- /dev/null +++ b/activesupport/lib/active_support/deprecation/constant_accessor.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module ActiveSupport + class Deprecation + # DeprecatedConstantAccessor transforms a constant into a deprecated one by + # hooking +const_missing+. + # + # It takes the names of an old (deprecated) constant and of a new constant + # (both in string form) and optionally a deprecator. The deprecator defaults + # to +ActiveSupport::Deprecator+ if none is specified. + # + # The deprecated constant now returns the same object as the new one rather + # than a proxy object, so it can be used transparently in +rescue+ blocks + # etc. + # + # PLANETS = %w(mercury venus earth mars jupiter saturn uranus neptune pluto) + # + # # (In a later update, the original implementation of `PLANETS` has been removed.) + # + # PLANETS_POST_2006 = %w(mercury venus earth mars jupiter saturn uranus neptune) + # include ActiveSupport::Deprecation::DeprecatedConstantAccessor + # deprecate_constant 'PLANETS', 'PLANETS_POST_2006' + # + # PLANETS.map { |planet| planet.capitalize } + # # => DEPRECATION WARNING: PLANETS is deprecated! Use PLANETS_POST_2006 instead. + # (Backtrace information…) + # ["Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune"] + module DeprecatedConstantAccessor + def self.included(base) + require "active_support/inflector/methods" + + extension = Module.new do + def const_missing(missing_const_name) + if class_variable_defined?(:@@_deprecated_constants) + if (replacement = class_variable_get(:@@_deprecated_constants)[missing_const_name.to_s]) + replacement[:deprecator].warn(replacement[:message] || "#{name}::#{missing_const_name} is deprecated! Use #{replacement[:new]} instead.", caller_locations) + return ActiveSupport::Inflector.constantize(replacement[:new].to_s) + end + end + super + end + + def deprecate_constant(const_name, new_constant, message: nil, deprecator: ActiveSupport::Deprecation.instance) + class_variable_set(:@@_deprecated_constants, {}) unless class_variable_defined?(:@@_deprecated_constants) + class_variable_get(:@@_deprecated_constants)[const_name.to_s] = { new: new_constant, message: message, deprecator: deprecator } + end + end + base.singleton_class.prepend extension + end + end + end +end diff --git a/activesupport/lib/active_support/deprecation/instance_delegator.rb b/activesupport/lib/active_support/deprecation/instance_delegator.rb new file mode 100644 index 0000000000..8beda373a2 --- /dev/null +++ b/activesupport/lib/active_support/deprecation/instance_delegator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "active_support/core_ext/kernel/singleton_class" +require "active_support/core_ext/module/delegation" + +module ActiveSupport + class Deprecation + module InstanceDelegator # :nodoc: + def self.included(base) + base.extend(ClassMethods) + base.singleton_class.prepend(OverrideDelegators) + base.public_class_method :new + end + + module ClassMethods # :nodoc: + def include(included_module) + included_module.instance_methods.each { |m| method_added(m) } + super + end + + def method_added(method_name) + singleton_class.delegate(method_name, to: :instance) + end + end + + module OverrideDelegators # :nodoc: + def warn(message = nil, callstack = nil) + callstack ||= caller_locations(2) + super + end + + def deprecation_warning(deprecated_method_name, message = nil, caller_backtrace = nil) + caller_backtrace ||= caller_locations(2) + super + end + end + end + end +end diff --git a/activesupport/lib/active_support/deprecation/method_wrappers.rb b/activesupport/lib/active_support/deprecation/method_wrappers.rb new file mode 100644 index 0000000000..d99571790f --- /dev/null +++ b/activesupport/lib/active_support/deprecation/method_wrappers.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/extract_options" + +module ActiveSupport + class Deprecation + module MethodWrapper + # Declare that a method has been deprecated. + # + # class Fred + # def aaa; end + # def bbb; end + # def ccc; end + # def ddd; end + # def eee; end + # end + # + # Using the default deprecator: + # ActiveSupport::Deprecation.deprecate_methods(Fred, :aaa, bbb: :zzz, ccc: 'use Bar#ccc instead') + # # => Fred + # + # Fred.new.aaa + # # DEPRECATION WARNING: aaa is deprecated and will be removed from Rails 5.1. (called from irb_binding at (irb):10) + # # => nil + # + # Fred.new.bbb + # # DEPRECATION WARNING: bbb is deprecated and will be removed from Rails 5.1 (use zzz instead). (called from irb_binding at (irb):11) + # # => nil + # + # Fred.new.ccc + # # DEPRECATION WARNING: ccc is deprecated and will be removed from Rails 5.1 (use Bar#ccc instead). (called from irb_binding at (irb):12) + # # => nil + # + # Passing in a custom deprecator: + # custom_deprecator = ActiveSupport::Deprecation.new('next-release', 'MyGem') + # ActiveSupport::Deprecation.deprecate_methods(Fred, ddd: :zzz, deprecator: custom_deprecator) + # # => [:ddd] + # + # Fred.new.ddd + # DEPRECATION WARNING: ddd is deprecated and will be removed from MyGem next-release (use zzz instead). (called from irb_binding at (irb):15) + # # => nil + # + # Using a custom deprecator directly: + # custom_deprecator = ActiveSupport::Deprecation.new('next-release', 'MyGem') + # custom_deprecator.deprecate_methods(Fred, eee: :zzz) + # # => [:eee] + # + # Fred.new.eee + # DEPRECATION WARNING: eee is deprecated and will be removed from MyGem next-release (use zzz instead). (called from irb_binding at (irb):18) + # # => nil + def deprecate_methods(target_module, *method_names) + options = method_names.extract_options! + deprecator = options.delete(:deprecator) || self + method_names += options.keys + mod = Module.new + + method_names.each do |method_name| + if target_module.method_defined?(method_name) || target_module.private_method_defined?(method_name) + aliased_method, punctuation = method_name.to_s.sub(/([?!=])$/, ""), $1 + with_method = "#{aliased_method}_with_deprecation#{punctuation}" + without_method = "#{aliased_method}_without_deprecation#{punctuation}" + + target_module.define_method(with_method) do |*args, &block| + deprecator.deprecation_warning(method_name, options[method_name]) + send(without_method, *args, &block) + end + + target_module.alias_method(without_method, method_name) + target_module.alias_method(method_name, with_method) + + case + when target_module.protected_method_defined?(without_method) + target_module.send(:protected, method_name) + when target_module.private_method_defined?(without_method) + target_module.send(:private, method_name) + end + else + mod.define_method(method_name) do |*args, &block| + deprecator.deprecation_warning(method_name, options[method_name]) + super(*args, &block) + end + end + end + + target_module.prepend(mod) unless mod.instance_methods(false).empty? + end + end + end +end diff --git a/activesupport/lib/active_support/deprecation/proxy_wrappers.rb b/activesupport/lib/active_support/deprecation/proxy_wrappers.rb new file mode 100644 index 0000000000..56f1e23136 --- /dev/null +++ b/activesupport/lib/active_support/deprecation/proxy_wrappers.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +module ActiveSupport + class Deprecation + class DeprecationProxy #:nodoc: + def self.new(*args, &block) + object = args.first + + return object unless object + super + end + + instance_methods.each { |m| undef_method m unless /^__|^object_id$/.match?(m) } + + # Don't give a deprecation warning on inspect since test/unit and error + # logs rely on it for diagnostics. + def inspect + target.inspect + end + + private + def method_missing(called, *args, &block) + warn caller_locations, called, args + target.__send__(called, *args, &block) + end + end + + # DeprecatedObjectProxy transforms an object into a deprecated one. It + # takes an object, a deprecation message and optionally a deprecator. The + # deprecator defaults to +ActiveSupport::Deprecator+ if none is specified. + # + # deprecated_object = ActiveSupport::Deprecation::DeprecatedObjectProxy.new(Object.new, "This object is now deprecated") + # # => #<Object:0x007fb9b34c34b0> + # + # deprecated_object.to_s + # DEPRECATION WARNING: This object is now deprecated. + # (Backtrace) + # # => "#<Object:0x007fb9b34c34b0>" + class DeprecatedObjectProxy < DeprecationProxy + def initialize(object, message, deprecator = ActiveSupport::Deprecation.instance) + @object = object + @message = message + @deprecator = deprecator + end + + private + def target + @object + end + + def warn(callstack, called, args) + @deprecator.warn(@message, callstack) + end + end + + # DeprecatedInstanceVariableProxy transforms an instance variable into a + # deprecated one. It takes an instance of a class, a method on that class + # and an instance variable. It optionally takes a deprecator as the last + # argument. The deprecator defaults to +ActiveSupport::Deprecator+ if none + # is specified. + # + # class Example + # def initialize + # @request = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(self, :request, :@request) + # @_request = :special_request + # end + # + # def request + # @_request + # end + # + # def old_request + # @request + # end + # end + # + # example = Example.new + # # => #<Example:0x007fb9b31090b8 @_request=:special_request, @request=:special_request> + # + # example.old_request.to_s + # # => DEPRECATION WARNING: @request is deprecated! Call request.to_s instead of + # @request.to_s + # (Backtrace information…) + # "special_request" + # + # example.request.to_s + # # => "special_request" + class DeprecatedInstanceVariableProxy < DeprecationProxy + def initialize(instance, method, var = "@#{method}", deprecator = ActiveSupport::Deprecation.instance) + @instance = instance + @method = method + @var = var + @deprecator = deprecator + end + + private + def target + @instance.__send__(@method) + end + + def warn(callstack, called, args) + @deprecator.warn("#{@var} is deprecated! Call #{@method}.#{called} instead of #{@var}.#{called}. Args: #{args.inspect}", callstack) + end + end + + # DeprecatedConstantProxy transforms a constant into a deprecated one. It + # takes the names of an old (deprecated) constant and of a new constant + # (both in string form) and optionally a deprecator. The deprecator defaults + # to +ActiveSupport::Deprecator+ if none is specified. The deprecated constant + # now returns the value of the new one. + # + # PLANETS = %w(mercury venus earth mars jupiter saturn uranus neptune pluto) + # + # # (In a later update, the original implementation of `PLANETS` has been removed.) + # + # PLANETS_POST_2006 = %w(mercury venus earth mars jupiter saturn uranus neptune) + # PLANETS = ActiveSupport::Deprecation::DeprecatedConstantProxy.new('PLANETS', 'PLANETS_POST_2006') + # + # PLANETS.map { |planet| planet.capitalize } + # # => DEPRECATION WARNING: PLANETS is deprecated! Use PLANETS_POST_2006 instead. + # (Backtrace information…) + # ["Mercury", "Venus", "Earth", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune"] + class DeprecatedConstantProxy < DeprecationProxy + def initialize(old_const, new_const, deprecator = ActiveSupport::Deprecation.instance, message: "#{old_const} is deprecated! Use #{new_const} instead.") + require "active_support/inflector/methods" + + @old_const = old_const + @new_const = new_const + @deprecator = deprecator + @message = message + end + + # Returns the class of the new constant. + # + # PLANETS_POST_2006 = %w(mercury venus earth mars jupiter saturn uranus neptune) + # PLANETS = ActiveSupport::Deprecation::DeprecatedConstantProxy.new('PLANETS', 'PLANETS_POST_2006') + # PLANETS.class # => Array + def class + target.class + end + + private + def target + ActiveSupport::Inflector.constantize(@new_const.to_s) + end + + def warn(callstack, called, args) + @deprecator.warn(@message, callstack) + end + end + end +end diff --git a/activesupport/lib/active_support/deprecation/reporting.rb b/activesupport/lib/active_support/deprecation/reporting.rb new file mode 100644 index 0000000000..7075b5b869 --- /dev/null +++ b/activesupport/lib/active_support/deprecation/reporting.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "rbconfig" + +module ActiveSupport + class Deprecation + module Reporting + # Whether to print a message (silent mode) + attr_accessor :silenced + # Name of gem where method is deprecated + attr_accessor :gem_name + + # Outputs a deprecation warning to the output configured by + # <tt>ActiveSupport::Deprecation.behavior</tt>. + # + # ActiveSupport::Deprecation.warn('something broke!') + # # => "DEPRECATION WARNING: something broke! (called from your_code.rb:1)" + def warn(message = nil, callstack = nil) + return if silenced + + callstack ||= caller_locations(2) + deprecation_message(callstack, message).tap do |m| + behavior.each { |b| b.call(m, callstack, deprecation_horizon, gem_name) } + end + end + + # Silence deprecation warnings within the block. + # + # ActiveSupport::Deprecation.warn('something broke!') + # # => "DEPRECATION WARNING: something broke! (called from your_code.rb:1)" + # + # ActiveSupport::Deprecation.silence do + # ActiveSupport::Deprecation.warn('something broke!') + # end + # # => nil + def silence + old_silenced, @silenced = @silenced, true + yield + ensure + @silenced = old_silenced + end + + def deprecation_warning(deprecated_method_name, message = nil, caller_backtrace = nil) + caller_backtrace ||= caller_locations(2) + deprecated_method_warning(deprecated_method_name, message).tap do |msg| + warn(msg, caller_backtrace) + end + end + + private + # Outputs a deprecation warning message + # + # deprecated_method_warning(:method_name) + # # => "method_name is deprecated and will be removed from Rails #{deprecation_horizon}" + # deprecated_method_warning(:method_name, :another_method) + # # => "method_name is deprecated and will be removed from Rails #{deprecation_horizon} (use another_method instead)" + # deprecated_method_warning(:method_name, "Optional message") + # # => "method_name is deprecated and will be removed from Rails #{deprecation_horizon} (Optional message)" + def deprecated_method_warning(method_name, message = nil) + warning = "#{method_name} is deprecated and will be removed from #{gem_name} #{deprecation_horizon}" + case message + when Symbol then "#{warning} (use #{message} instead)" + when String then "#{warning} (#{message})" + else warning + end + end + + def deprecation_message(callstack, message = nil) + message ||= "You are using deprecated behavior which will be removed from the next major or minor release." + "DEPRECATION WARNING: #{message} #{deprecation_caller_message(callstack)}" + end + + def deprecation_caller_message(callstack) + file, line, method = extract_callstack(callstack) + if file + if line && method + "(called from #{method} at #{file}:#{line})" + else + "(called from #{file}:#{line})" + end + end + end + + def extract_callstack(callstack) + return _extract_callstack(callstack) if callstack.first.is_a? String + + offending_line = callstack.find { |frame| + frame.absolute_path && !ignored_callstack(frame.absolute_path) + } || callstack.first + + [offending_line.path, offending_line.lineno, offending_line.label] + end + + def _extract_callstack(callstack) + warn "Please pass `caller_locations` to the deprecation API" if $VERBOSE + offending_line = callstack.find { |line| !ignored_callstack(line) } || callstack.first + + if offending_line + if md = offending_line.match(/^(.+?):(\d+)(?::in `(.*?)')?/) + md.captures + else + offending_line + end + end + end + + RAILS_GEM_ROOT = File.expand_path("../../../..", __dir__) + "/" + + def ignored_callstack(path) + path.start_with?(RAILS_GEM_ROOT) || path.start_with?(RbConfig::CONFIG["rubylibdir"]) + end + end + end +end diff --git a/activesupport/lib/active_support/descendants_tracker.rb b/activesupport/lib/active_support/descendants_tracker.rb new file mode 100644 index 0000000000..05236d3162 --- /dev/null +++ b/activesupport/lib/active_support/descendants_tracker.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module ActiveSupport + # This module provides an internal implementation to track descendants + # which is faster than iterating through ObjectSpace. + module DescendantsTracker + @@direct_descendants = {} + + class << self + def direct_descendants(klass) + @@direct_descendants[klass] || [] + end + + def descendants(klass) + arr = [] + accumulate_descendants(klass, arr) + arr + end + + def clear + if defined? ActiveSupport::Dependencies + @@direct_descendants.each do |klass, descendants| + if ActiveSupport::Dependencies.autoloaded?(klass) + @@direct_descendants.delete(klass) + else + descendants.reject! { |v| ActiveSupport::Dependencies.autoloaded?(v) } + end + end + else + @@direct_descendants.clear + end + end + + # This is the only method that is not thread safe, but is only ever called + # during the eager loading phase. + def store_inherited(klass, descendant) + (@@direct_descendants[klass] ||= []) << descendant + end + + private + + def accumulate_descendants(klass, acc) + if direct_descendants = @@direct_descendants[klass] + acc.concat(direct_descendants) + direct_descendants.each { |direct_descendant| accumulate_descendants(direct_descendant, acc) } + end + end + end + + def inherited(base) + DescendantsTracker.store_inherited(self, base) + super + end + + def direct_descendants + DescendantsTracker.direct_descendants(self) + end + + def descendants + DescendantsTracker.descendants(self) + end + end +end diff --git a/activesupport/lib/active_support/digest.rb b/activesupport/lib/active_support/digest.rb new file mode 100644 index 0000000000..fba10fbdcf --- /dev/null +++ b/activesupport/lib/active_support/digest.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ActiveSupport + class Digest #:nodoc: + class <<self + def hash_digest_class + @hash_digest_class ||= ::Digest::MD5 + end + + def hash_digest_class=(klass) + raise ArgumentError, "#{klass} is expected to implement hexdigest class method" unless klass.respond_to?(:hexdigest) + @hash_digest_class = klass + end + + def hexdigest(arg) + hash_digest_class.hexdigest(arg)[0...32] + end + end + end +end diff --git a/activesupport/lib/active_support/duration.rb b/activesupport/lib/active_support/duration.rb new file mode 100644 index 0000000000..314c926ac0 --- /dev/null +++ b/activesupport/lib/active_support/duration.rb @@ -0,0 +1,431 @@ +# frozen_string_literal: true + +require "active_support/core_ext/array/conversions" +require "active_support/core_ext/module/delegation" +require "active_support/core_ext/object/acts_like" +require "active_support/core_ext/string/filters" +require "active_support/deprecation" + +module ActiveSupport + # Provides accurate date and time measurements using Date#advance and + # Time#advance, respectively. It mainly supports the methods on Numeric. + # + # 1.month.ago # equivalent to Time.now.advance(months: -1) + class Duration + class Scalar < Numeric #:nodoc: + attr_reader :value + delegate :to_i, :to_f, :to_s, to: :value + + def initialize(value) + @value = value + end + + def coerce(other) + [Scalar.new(other), self] + end + + def -@ + Scalar.new(-value) + end + + def <=>(other) + if Scalar === other || Duration === other + value <=> other.value + elsif Numeric === other + value <=> other + else + nil + end + end + + def +(other) + if Duration === other + seconds = value + other.parts[:seconds] + new_parts = other.parts.merge(seconds: seconds) + new_value = value + other.value + + Duration.new(new_value, new_parts) + else + calculate(:+, other) + end + end + + def -(other) + if Duration === other + seconds = value - other.parts[:seconds] + new_parts = other.parts.map { |part, other_value| [part, -other_value] }.to_h + new_parts = new_parts.merge(seconds: seconds) + new_value = value - other.value + + Duration.new(new_value, new_parts) + else + calculate(:-, other) + end + end + + def *(other) + if Duration === other + new_parts = other.parts.map { |part, other_value| [part, value * other_value] }.to_h + new_value = value * other.value + + Duration.new(new_value, new_parts) + else + calculate(:*, other) + end + end + + def /(other) + if Duration === other + value / other.value + else + calculate(:/, other) + end + end + + def %(other) + if Duration === other + Duration.build(value % other.value) + else + calculate(:%, other) + end + end + + private + def calculate(op, other) + if Scalar === other + Scalar.new(value.public_send(op, other.value)) + elsif Numeric === other + Scalar.new(value.public_send(op, other)) + else + raise_type_error(other) + end + end + + def raise_type_error(other) + raise TypeError, "no implicit conversion of #{other.class} into #{self.class}" + end + end + + SECONDS_PER_MINUTE = 60 + SECONDS_PER_HOUR = 3600 + SECONDS_PER_DAY = 86400 + SECONDS_PER_WEEK = 604800 + SECONDS_PER_MONTH = 2629746 # 1/12 of a gregorian year + SECONDS_PER_YEAR = 31556952 # length of a gregorian year (365.2425 days) + + PARTS_IN_SECONDS = { + seconds: 1, + minutes: SECONDS_PER_MINUTE, + hours: SECONDS_PER_HOUR, + days: SECONDS_PER_DAY, + weeks: SECONDS_PER_WEEK, + months: SECONDS_PER_MONTH, + years: SECONDS_PER_YEAR + }.freeze + + PARTS = [:years, :months, :weeks, :days, :hours, :minutes, :seconds].freeze + + attr_accessor :value, :parts + + autoload :ISO8601Parser, "active_support/duration/iso8601_parser" + autoload :ISO8601Serializer, "active_support/duration/iso8601_serializer" + + class << self + # Creates a new Duration from string formatted according to ISO 8601 Duration. + # + # See {ISO 8601}[https://en.wikipedia.org/wiki/ISO_8601#Durations] for more information. + # This method allows negative parts to be present in pattern. + # If invalid string is provided, it will raise +ActiveSupport::Duration::ISO8601Parser::ParsingError+. + def parse(iso8601duration) + parts = ISO8601Parser.new(iso8601duration).parse! + new(calculate_total_seconds(parts), parts) + end + + def ===(other) #:nodoc: + other.is_a?(Duration) + rescue ::NoMethodError + false + end + + def seconds(value) #:nodoc: + new(value, [[:seconds, value]]) + end + + def minutes(value) #:nodoc: + new(value * SECONDS_PER_MINUTE, [[:minutes, value]]) + end + + def hours(value) #:nodoc: + new(value * SECONDS_PER_HOUR, [[:hours, value]]) + end + + def days(value) #:nodoc: + new(value * SECONDS_PER_DAY, [[:days, value]]) + end + + def weeks(value) #:nodoc: + new(value * SECONDS_PER_WEEK, [[:weeks, value]]) + end + + def months(value) #:nodoc: + new(value * SECONDS_PER_MONTH, [[:months, value]]) + end + + def years(value) #:nodoc: + new(value * SECONDS_PER_YEAR, [[:years, value]]) + end + + # Creates a new Duration from a seconds value that is converted + # to the individual parts: + # + # ActiveSupport::Duration.build(31556952).parts # => {:years=>1} + # ActiveSupport::Duration.build(2716146).parts # => {:months=>1, :days=>1} + # + def build(value) + parts = {} + remainder = value.to_f + + PARTS.each do |part| + unless part == :seconds + part_in_seconds = PARTS_IN_SECONDS[part] + parts[part] = remainder.div(part_in_seconds) + remainder = (remainder % part_in_seconds).round(9) + end + end + + parts[:seconds] = remainder + + new(value, parts) + end + + private + + def calculate_total_seconds(parts) + parts.inject(0) do |total, (part, value)| + total + value * PARTS_IN_SECONDS[part] + end + end + end + + def initialize(value, parts) #:nodoc: + @value, @parts = value, parts.to_h + @parts.default = 0 + @parts.reject! { |k, v| v.zero? } + end + + def coerce(other) #:nodoc: + if Scalar === other + [other, self] + else + [Scalar.new(other), self] + end + end + + # Compares one Duration with another or a Numeric to this Duration. + # Numeric values are treated as seconds. + def <=>(other) + if Duration === other + value <=> other.value + elsif Numeric === other + value <=> other + end + end + + # Adds another Duration or a Numeric to this Duration. Numeric values + # are treated as seconds. + def +(other) + if Duration === other + parts = @parts.dup + other.parts.each do |(key, value)| + parts[key] += value + end + Duration.new(value + other.value, parts) + else + seconds = @parts[:seconds] + other + Duration.new(value + other, @parts.merge(seconds: seconds)) + end + end + + # Subtracts another Duration or a Numeric from this Duration. Numeric + # values are treated as seconds. + def -(other) + self + (-other) + end + + # Multiplies this Duration by a Numeric and returns a new Duration. + def *(other) + if Scalar === other || Duration === other + Duration.new(value * other.value, parts.map { |type, number| [type, number * other.value] }) + elsif Numeric === other + Duration.new(value * other, parts.map { |type, number| [type, number * other] }) + else + raise_type_error(other) + end + end + + # Divides this Duration by a Numeric and returns a new Duration. + def /(other) + if Scalar === other + Duration.new(value / other.value, parts.map { |type, number| [type, number / other.value] }) + elsif Duration === other + value / other.value + elsif Numeric === other + Duration.new(value / other, parts.map { |type, number| [type, number / other] }) + else + raise_type_error(other) + end + end + + # Returns the modulo of this Duration by another Duration or Numeric. + # Numeric values are treated as seconds. + def %(other) + if Duration === other || Scalar === other + Duration.build(value % other.value) + elsif Numeric === other + Duration.build(value % other) + else + raise_type_error(other) + end + end + + def -@ #:nodoc: + Duration.new(-value, parts.map { |type, number| [type, -number] }) + end + + def is_a?(klass) #:nodoc: + Duration == klass || value.is_a?(klass) + end + alias :kind_of? :is_a? + + def instance_of?(klass) # :nodoc: + Duration == klass || value.instance_of?(klass) + end + + # Returns +true+ if +other+ is also a Duration instance with the + # same +value+, or if <tt>other == value</tt>. + def ==(other) + if Duration === other + other.value == value + else + other == value + end + end + + # Returns the amount of seconds a duration covers as a string. + # For more information check to_i method. + # + # 1.day.to_s # => "86400" + def to_s + @value.to_s + end + + # Returns the number of seconds that this Duration represents. + # + # 1.minute.to_i # => 60 + # 1.hour.to_i # => 3600 + # 1.day.to_i # => 86400 + # + # Note that this conversion makes some assumptions about the + # duration of some periods, e.g. months are always 1/12 of year + # and years are 365.2425 days: + # + # # equivalent to (1.year / 12).to_i + # 1.month.to_i # => 2629746 + # + # # equivalent to 365.2425.days.to_i + # 1.year.to_i # => 31556952 + # + # In such cases, Ruby's core + # Date[http://ruby-doc.org/stdlib/libdoc/date/rdoc/Date.html] and + # Time[http://ruby-doc.org/stdlib/libdoc/time/rdoc/Time.html] should be used for precision + # date and time arithmetic. + def to_i + @value.to_i + end + + # Returns +true+ if +other+ is also a Duration instance, which has the + # same parts as this one. + def eql?(other) + Duration === other && other.value.eql?(value) + end + + def hash + @value.hash + end + + # Calculates a new Time or Date that is as far in the future + # as this Duration represents. + def since(time = ::Time.current) + sum(1, time) + end + alias :from_now :since + alias :after :since + + # Calculates a new Time or Date that is as far in the past + # as this Duration represents. + def ago(time = ::Time.current) + sum(-1, time) + end + alias :until :ago + alias :before :ago + + def inspect #:nodoc: + return "0 seconds" if parts.empty? + + parts. + sort_by { |unit, _ | PARTS.index(unit) }. + map { |unit, val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}" }. + to_sentence(locale: ::I18n.default_locale) + end + + def as_json(options = nil) #:nodoc: + to_i + end + + def init_with(coder) #:nodoc: + initialize(coder["value"], coder["parts"]) + end + + def encode_with(coder) #:nodoc: + coder.map = { "value" => @value, "parts" => @parts } + end + + # Build ISO 8601 Duration string for this duration. + # The +precision+ parameter can be used to limit seconds' precision of duration. + def iso8601(precision: nil) + ISO8601Serializer.new(self, precision: precision).serialize + end + + private + + def sum(sign, time = ::Time.current) + parts.inject(time) do |t, (type, number)| + if t.acts_like?(:time) || t.acts_like?(:date) + if type == :seconds + t.since(sign * number) + elsif type == :minutes + t.since(sign * number * 60) + elsif type == :hours + t.since(sign * number * 3600) + else + t.advance(type => sign * number) + end + else + raise ::ArgumentError, "expected a time or date, got #{time.inspect}" + end + end + end + + def respond_to_missing?(method, _) + value.respond_to?(method) + end + + def method_missing(method, *args, &block) + value.public_send(method, *args, &block) + end + + def raise_type_error(other) + raise TypeError, "no implicit conversion of #{other.class} into #{self.class}" + end + end +end diff --git a/activesupport/lib/active_support/duration/iso8601_parser.rb b/activesupport/lib/active_support/duration/iso8601_parser.rb new file mode 100644 index 0000000000..d3233e6111 --- /dev/null +++ b/activesupport/lib/active_support/duration/iso8601_parser.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "strscan" + +module ActiveSupport + class Duration + # Parses a string formatted according to ISO 8601 Duration into the hash. + # + # See {ISO 8601}[https://en.wikipedia.org/wiki/ISO_8601#Durations] for more information. + # + # This parser allows negative parts to be present in pattern. + class ISO8601Parser # :nodoc: + class ParsingError < ::ArgumentError; end + + PERIOD_OR_COMMA = /\.|,/ + PERIOD = "." + COMMA = "," + + SIGN_MARKER = /\A\-|\+|/ + DATE_MARKER = /P/ + TIME_MARKER = /T/ + DATE_COMPONENT = /(\-?\d+(?:[.,]\d+)?)(Y|M|D|W)/ + TIME_COMPONENT = /(\-?\d+(?:[.,]\d+)?)(H|M|S)/ + + DATE_TO_PART = { "Y" => :years, "M" => :months, "W" => :weeks, "D" => :days } + TIME_TO_PART = { "H" => :hours, "M" => :minutes, "S" => :seconds } + + DATE_COMPONENTS = [:years, :months, :days] + TIME_COMPONENTS = [:hours, :minutes, :seconds] + + attr_reader :parts, :scanner + attr_accessor :mode, :sign + + def initialize(string) + @scanner = StringScanner.new(string) + @parts = {} + @mode = :start + @sign = 1 + end + + def parse! + while !finished? + case mode + when :start + if scan(SIGN_MARKER) + self.sign = (scanner.matched == "-") ? -1 : 1 + self.mode = :sign + else + raise_parsing_error + end + + when :sign + if scan(DATE_MARKER) + self.mode = :date + else + raise_parsing_error + end + + when :date + if scan(TIME_MARKER) + self.mode = :time + elsif scan(DATE_COMPONENT) + parts[DATE_TO_PART[scanner[2]]] = number * sign + else + raise_parsing_error + end + + when :time + if scan(TIME_COMPONENT) + parts[TIME_TO_PART[scanner[2]]] = number * sign + else + raise_parsing_error + end + + end + end + + validate! + parts + end + + private + + def finished? + scanner.eos? + end + + # Parses number which can be a float with either comma or period. + def number + PERIOD_OR_COMMA.match?(scanner[1]) ? scanner[1].tr(COMMA, PERIOD).to_f : scanner[1].to_i + end + + def scan(pattern) + scanner.scan(pattern) + end + + def raise_parsing_error(reason = nil) + raise ParsingError, "Invalid ISO 8601 duration: #{scanner.string.inspect} #{reason}".strip + end + + # Checks for various semantic errors as stated in ISO 8601 standard. + def validate! + raise_parsing_error("is empty duration") if parts.empty? + + # Mixing any of Y, M, D with W is invalid. + if parts.key?(:weeks) && (parts.keys & DATE_COMPONENTS).any? + raise_parsing_error("mixing weeks with other date parts not allowed") + end + + # Specifying an empty T part is invalid. + if mode == :time && (parts.keys & TIME_COMPONENTS).empty? + raise_parsing_error("time part marker is present but time part is empty") + end + + fractions = parts.values.reject(&:zero?).select { |a| (a % 1) != 0 } + unless fractions.empty? || (fractions.size == 1 && fractions.last == @parts.values.reject(&:zero?).last) + raise_parsing_error "(only last part can be fractional)" + end + + true + end + end + end +end diff --git a/activesupport/lib/active_support/duration/iso8601_serializer.rb b/activesupport/lib/active_support/duration/iso8601_serializer.rb new file mode 100644 index 0000000000..1125454919 --- /dev/null +++ b/activesupport/lib/active_support/duration/iso8601_serializer.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/blank" + +module ActiveSupport + class Duration + # Serializes duration to string according to ISO 8601 Duration format. + class ISO8601Serializer # :nodoc: + def initialize(duration, precision: nil) + @duration = duration + @precision = precision + end + + # Builds and returns output string. + def serialize + parts, sign = normalize + return "PT0S" if parts.empty? + + output = +"P" + output << "#{parts[:years]}Y" if parts.key?(:years) + output << "#{parts[:months]}M" if parts.key?(:months) + output << "#{parts[:weeks]}W" if parts.key?(:weeks) + output << "#{parts[:days]}D" if parts.key?(:days) + time = +"" + time << "#{parts[:hours]}H" if parts.key?(:hours) + time << "#{parts[:minutes]}M" if parts.key?(:minutes) + if parts.key?(:seconds) + time << "#{sprintf(@precision ? "%0.0#{@precision}f" : '%g', parts[:seconds])}S" + end + output << "T#{time}" unless time.empty? + "#{sign}#{output}" + end + + private + + # Return pair of duration's parts and whole duration sign. + # Parts are summarized (as they can become repetitive due to addition, etc). + # Zero parts are removed as not significant. + # If all parts are negative it will negate all of them and return minus as a sign. + def normalize + parts = @duration.parts.each_with_object(Hash.new(0)) do |(k, v), p| + p[k] += v unless v.zero? + end + # If all parts are negative - let's make a negative duration + sign = "" + if parts.values.all? { |v| v < 0 } + sign = "-" + parts.transform_values!(&:-@) + end + [parts, sign] + end + end + end +end diff --git a/activesupport/lib/active_support/encrypted_configuration.rb b/activesupport/lib/active_support/encrypted_configuration.rb new file mode 100644 index 0000000000..cc1d026737 --- /dev/null +++ b/activesupport/lib/active_support/encrypted_configuration.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "yaml" +require "active_support/encrypted_file" +require "active_support/ordered_options" +require "active_support/core_ext/object/inclusion" +require "active_support/core_ext/module/delegation" + +module ActiveSupport + class EncryptedConfiguration < EncryptedFile + delegate :[], :fetch, to: :config + delegate_missing_to :options + + def initialize(config_path:, key_path:, env_key:, raise_if_missing_key:) + super content_path: config_path, key_path: key_path, + env_key: env_key, raise_if_missing_key: raise_if_missing_key + end + + # Allow a config to be started without a file present + def read + super + rescue ActiveSupport::EncryptedFile::MissingContentError + "" + end + + def write(contents) + deserialize(contents) + + super + end + + def config + @config ||= deserialize(read).deep_symbolize_keys + end + + private + def options + @options ||= ActiveSupport::InheritableOptions.new(config) + end + + def deserialize(config) + YAML.load(config).presence || {} + end + end +end diff --git a/activesupport/lib/active_support/encrypted_file.rb b/activesupport/lib/active_support/encrypted_file.rb new file mode 100644 index 0000000000..c66f1b557e --- /dev/null +++ b/activesupport/lib/active_support/encrypted_file.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "pathname" +require "active_support/message_encryptor" + +module ActiveSupport + class EncryptedFile + class MissingContentError < RuntimeError + def initialize(content_path) + super "Missing encrypted content file in #{content_path}." + end + end + + class MissingKeyError < RuntimeError + def initialize(key_path:, env_key:) + super \ + "Missing encryption key to decrypt file with. " + + "Ask your team for your master key and write it to #{key_path} or put it in the ENV['#{env_key}']." + end + end + + CIPHER = "aes-128-gcm" + + def self.generate_key + SecureRandom.hex(ActiveSupport::MessageEncryptor.key_len(CIPHER)) + end + + + attr_reader :content_path, :key_path, :env_key, :raise_if_missing_key + + def initialize(content_path:, key_path:, env_key:, raise_if_missing_key:) + @content_path, @key_path = Pathname.new(content_path), Pathname.new(key_path) + @env_key, @raise_if_missing_key = env_key, raise_if_missing_key + end + + def key + read_env_key || read_key_file || handle_missing_key + end + + def read + if !key.nil? && content_path.exist? + decrypt content_path.binread + else + raise MissingContentError, content_path + end + end + + def write(contents) + IO.binwrite "#{content_path}.tmp", encrypt(contents) + FileUtils.mv "#{content_path}.tmp", content_path + end + + def change(&block) + writing read, &block + end + + + private + def writing(contents) + tmp_file = "#{Process.pid}.#{content_path.basename.to_s.chomp('.enc')}" + tmp_path = Pathname.new File.join(Dir.tmpdir, tmp_file) + tmp_path.binwrite contents + + yield tmp_path + + updated_contents = tmp_path.binread + + write(updated_contents) if updated_contents != contents + ensure + FileUtils.rm(tmp_path) if tmp_path.exist? + end + + + def encrypt(contents) + encryptor.encrypt_and_sign contents + end + + def decrypt(contents) + encryptor.decrypt_and_verify contents + end + + def encryptor + @encryptor ||= ActiveSupport::MessageEncryptor.new([ key ].pack("H*"), cipher: CIPHER) + end + + + def read_env_key + ENV[env_key] + end + + def read_key_file + key_path.binread.strip if key_path.exist? + end + + def handle_missing_key + raise MissingKeyError, key_path: key_path, env_key: env_key if raise_if_missing_key + end + end +end diff --git a/activesupport/lib/active_support/evented_file_update_checker.rb b/activesupport/lib/active_support/evented_file_update_checker.rb new file mode 100644 index 0000000000..54bc5c0ae5 --- /dev/null +++ b/activesupport/lib/active_support/evented_file_update_checker.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require "set" +require "pathname" +require "concurrent/atomic/atomic_boolean" + +module ActiveSupport + # Allows you to "listen" to changes in a file system. + # The evented file updater does not hit disk when checking for updates + # instead it uses platform specific file system events to trigger a change + # in state. + # + # The file checker takes an array of files to watch or a hash specifying directories + # and file extensions to watch. It also takes a block that is called when + # EventedFileUpdateChecker#execute is run or when EventedFileUpdateChecker#execute_if_updated + # is run and there have been changes to the file system. + # + # Note: Forking will cause the first call to `updated?` to return `true`. + # + # Example: + # + # checker = ActiveSupport::EventedFileUpdateChecker.new(["/tmp/foo"]) { puts "changed" } + # checker.updated? + # # => false + # checker.execute_if_updated + # # => nil + # + # FileUtils.touch("/tmp/foo") + # + # checker.updated? + # # => true + # checker.execute_if_updated + # # => "changed" + # + class EventedFileUpdateChecker #:nodoc: all + def initialize(files, dirs = {}, &block) + unless block + raise ArgumentError, "A block is required to initialize an EventedFileUpdateChecker" + end + + @ph = PathHelper.new + @files = files.map { |f| @ph.xpath(f) }.to_set + + @dirs = {} + dirs.each do |dir, exts| + @dirs[@ph.xpath(dir)] = Array(exts).map { |ext| @ph.normalize_extension(ext) } + end + + @block = block + @updated = Concurrent::AtomicBoolean.new(false) + @lcsp = @ph.longest_common_subpath(@dirs.keys) + @pid = Process.pid + @boot_mutex = Mutex.new + + dtw = directories_to_watch + @dtw, @missing = dtw.partition(&:exist?) + + if @dtw.any? + # Loading listen triggers warnings. These are originated by a legit + # usage of attr_* macros for private attributes, but adds a lot of noise + # to our test suite. Thus, we lazy load it and disable warnings locally. + silence_warnings do + require "listen" + rescue LoadError => e + raise LoadError, "Could not load the 'listen' gem. Add `gem 'listen'` to the development group of your Gemfile", e.backtrace + end + end + boot! + end + + def updated? + @boot_mutex.synchronize do + if @pid != Process.pid + boot! + @pid = Process.pid + @updated.make_true + end + end + + if @missing.any?(&:exist?) + @boot_mutex.synchronize do + appeared, @missing = @missing.partition(&:exist?) + shutdown! + + @dtw += appeared + boot! + + @updated.make_true + end + end + + @updated.true? + end + + def execute + @updated.make_false + @block.call + end + + def execute_if_updated + if updated? + yield if block_given? + execute + true + end + end + + private + def boot! + Listen.to(*@dtw, &method(:changed)).start + end + + def shutdown! + Listen.stop + end + + def changed(modified, added, removed) + unless updated? + @updated.make_true if (modified + added + removed).any? { |f| watching?(f) } + end + end + + def watching?(file) + file = @ph.xpath(file) + + if @files.member?(file) + true + elsif file.directory? + false + else + ext = @ph.normalize_extension(file.extname) + + file.dirname.ascend do |dir| + if @dirs.fetch(dir, []).include?(ext) + break true + elsif dir == @lcsp || dir.root? + break false + end + end + end + end + + def directories_to_watch + dtw = @files.map(&:dirname) + @dirs.keys + dtw.compact! + dtw.uniq! + + normalized_gem_paths = Gem.path.map { |path| File.join path, "" } + dtw = dtw.reject do |path| + normalized_gem_paths.any? { |gem_path| path.to_s.start_with?(gem_path) } + end + + @ph.filter_out_descendants(dtw) + end + + class PathHelper + def xpath(path) + Pathname.new(path).expand_path + end + + def normalize_extension(ext) + ext.to_s.sub(/\A\./, "") + end + + # Given a collection of Pathname objects returns the longest subpath + # common to all of them, or +nil+ if there is none. + def longest_common_subpath(paths) + return if paths.empty? + + lcsp = Pathname.new(paths[0]) + + paths[1..-1].each do |path| + until ascendant_of?(lcsp, path) + if lcsp.root? + # If we get here a root directory is not an ascendant of path. + # This may happen if there are paths in different drives on + # Windows. + return + else + lcsp = lcsp.parent + end + end + end + + lcsp + end + + # Returns the deepest existing ascendant, which could be the argument itself. + def existing_parent(dir) + dir.ascend do |ascendant| + break ascendant if ascendant.directory? + end + end + + # Filters out directories which are descendants of others in the collection (stable). + def filter_out_descendants(dirs) + return dirs if dirs.length < 2 + + dirs_sorted_by_nparts = dirs.sort_by { |dir| dir.each_filename.to_a.length } + descendants = [] + + until dirs_sorted_by_nparts.empty? + dir = dirs_sorted_by_nparts.shift + + dirs_sorted_by_nparts.reject! do |possible_descendant| + ascendant_of?(dir, possible_descendant) && descendants << possible_descendant + end + end + + # Array#- preserves order. + dirs - descendants + end + + private + + def ascendant_of?(base, other) + base != other && other.ascend do |ascendant| + break true if base == ascendant + end + end + end + end +end diff --git a/activesupport/lib/active_support/execution_wrapper.rb b/activesupport/lib/active_support/execution_wrapper.rb new file mode 100644 index 0000000000..ca810db584 --- /dev/null +++ b/activesupport/lib/active_support/execution_wrapper.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "active_support/callbacks" +require "concurrent/hash" + +module ActiveSupport + class ExecutionWrapper + include ActiveSupport::Callbacks + + Null = Object.new # :nodoc: + def Null.complete! # :nodoc: + end + + define_callbacks :run + define_callbacks :complete + + def self.to_run(*args, &block) + set_callback(:run, *args, &block) + end + + def self.to_complete(*args, &block) + set_callback(:complete, *args, &block) + end + + RunHook = Struct.new(:hook) do # :nodoc: + def before(target) + hook_state = target.send(:hook_state) + hook_state[hook] = hook.run + end + end + + CompleteHook = Struct.new(:hook) do # :nodoc: + def before(target) + hook_state = target.send(:hook_state) + if hook_state.key?(hook) + hook.complete hook_state[hook] + end + end + alias after before + end + + # Register an object to be invoked during both the +run+ and + # +complete+ steps. + # + # +hook.complete+ will be passed the value returned from +hook.run+, + # and will only be invoked if +run+ has previously been called. + # (Mostly, this means it won't be invoked if an exception occurs in + # a preceding +to_run+ block; all ordinary +to_complete+ blocks are + # invoked in that situation.) + def self.register_hook(hook, outer: false) + if outer + to_run RunHook.new(hook), prepend: true + to_complete :after, CompleteHook.new(hook) + else + to_run RunHook.new(hook) + to_complete CompleteHook.new(hook) + end + end + + # Run this execution. + # + # Returns an instance, whose +complete!+ method *must* be invoked + # after the work has been performed. + # + # Where possible, prefer +wrap+. + def self.run! + if active? + Null + else + new.tap do |instance| + success = nil + begin + instance.run! + success = true + ensure + instance.complete! unless success + end + end + end + end + + # Perform the work in the supplied block as an execution. + def self.wrap + return yield if active? + + instance = run! + begin + yield + ensure + instance.complete! + end + end + + class << self # :nodoc: + attr_accessor :active + end + + def self.inherited(other) # :nodoc: + super + other.active = Concurrent::Hash.new + end + + self.active = Concurrent::Hash.new + + def self.active? # :nodoc: + @active[Thread.current] + end + + def run! # :nodoc: + self.class.active[Thread.current] = true + run_callbacks(:run) + end + + # Complete this in-flight execution. This method *must* be called + # exactly once on the result of any call to +run!+. + # + # Where possible, prefer +wrap+. + def complete! + run_callbacks(:complete) + ensure + self.class.active.delete Thread.current + end + + private + def hook_state + @_hook_state ||= {} + end + end +end diff --git a/activesupport/lib/active_support/executor.rb b/activesupport/lib/active_support/executor.rb new file mode 100644 index 0000000000..ce391b07ec --- /dev/null +++ b/activesupport/lib/active_support/executor.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "active_support/execution_wrapper" + +module ActiveSupport + class Executor < ExecutionWrapper + end +end diff --git a/activesupport/lib/active_support/file_update_checker.rb b/activesupport/lib/active_support/file_update_checker.rb new file mode 100644 index 0000000000..1a0bb10815 --- /dev/null +++ b/activesupport/lib/active_support/file_update_checker.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "active_support/core_ext/time/calculations" + +module ActiveSupport + # FileUpdateChecker specifies the API used by Rails to watch files + # and control reloading. The API depends on four methods: + # + # * +initialize+ which expects two parameters and one block as + # described below. + # + # * +updated?+ which returns a boolean if there were updates in + # the filesystem or not. + # + # * +execute+ which executes the given block on initialization + # and updates the latest watched files and timestamp. + # + # * +execute_if_updated+ which just executes the block if it was updated. + # + # After initialization, a call to +execute_if_updated+ must execute + # the block only if there was really a change in the filesystem. + # + # This class is used by Rails to reload the I18n framework whenever + # they are changed upon a new request. + # + # i18n_reloader = ActiveSupport::FileUpdateChecker.new(paths) do + # I18n.reload! + # end + # + # ActiveSupport::Reloader.to_prepare do + # i18n_reloader.execute_if_updated + # end + class FileUpdateChecker + # It accepts two parameters on initialization. The first is an array + # of files and the second is an optional hash of directories. The hash must + # have directories as keys and the value is an array of extensions to be + # watched under that directory. + # + # This method must also receive a block that will be called once a path + # changes. The array of files and list of directories cannot be changed + # after FileUpdateChecker has been initialized. + def initialize(files, dirs = {}, &block) + unless block + raise ArgumentError, "A block is required to initialize a FileUpdateChecker" + end + + @files = files.freeze + @glob = compile_glob(dirs) + @block = block + + @watched = nil + @updated_at = nil + + @last_watched = watched + @last_update_at = updated_at(@last_watched) + end + + # Check if any of the entries were updated. If so, the watched and/or + # updated_at values are cached until the block is executed via +execute+ + # or +execute_if_updated+. + def updated? + current_watched = watched + if @last_watched.size != current_watched.size + @watched = current_watched + true + else + current_updated_at = updated_at(current_watched) + if @last_update_at < current_updated_at + @watched = current_watched + @updated_at = current_updated_at + true + else + false + end + end + end + + # Executes the given block and updates the latest watched files and + # timestamp. + def execute + @last_watched = watched + @last_update_at = updated_at(@last_watched) + @block.call + ensure + @watched = nil + @updated_at = nil + end + + # Execute the block given if updated. + def execute_if_updated + if updated? + yield if block_given? + execute + true + else + false + end + end + + private + + def watched + @watched || begin + all = @files.select { |f| File.exist?(f) } + all.concat(Dir[@glob]) if @glob + all + end + end + + def updated_at(paths) + @updated_at || max_mtime(paths) || Time.at(0) + end + + # This method returns the maximum mtime of the files in +paths+, or +nil+ + # if the array is empty. + # + # Files with a mtime in the future are ignored. Such abnormal situation + # can happen for example if the user changes the clock by hand. It is + # healthy to consider this edge case because with mtimes in the future + # reloading is not triggered. + def max_mtime(paths) + time_now = Time.now + max_mtime = nil + + # Time comparisons are performed with #compare_without_coercion because + # AS redefines these operators in a way that is much slower and does not + # bring any benefit in this particular code. + # + # Read t1.compare_without_coercion(t2) < 0 as t1 < t2. + paths.each do |path| + mtime = File.mtime(path) + + next if time_now.compare_without_coercion(mtime) < 0 + + if max_mtime.nil? || max_mtime.compare_without_coercion(mtime) < 0 + max_mtime = mtime + end + end + + max_mtime + end + + def compile_glob(hash) + hash.freeze # Freeze so changes aren't accidentally pushed + return if hash.empty? + + globs = hash.map do |key, value| + "#{escape(key)}/**/*#{compile_ext(value)}" + end + "{#{globs.join(",")}}" + end + + def escape(key) + key.gsub(",", '\,') + end + + def compile_ext(array) + array = Array(array) + return if array.empty? + ".{#{array.join(",")}}" + end + end +end diff --git a/activesupport/lib/active_support/gem_version.rb b/activesupport/lib/active_support/gem_version.rb new file mode 100644 index 0000000000..c951ad16a3 --- /dev/null +++ b/activesupport/lib/active_support/gem_version.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActiveSupport + # Returns the version of the currently loaded Active Support as a <tt>Gem::Version</tt>. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 6 + MINOR = 0 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/activesupport/lib/active_support/gzip.rb b/activesupport/lib/active_support/gzip.rb new file mode 100644 index 0000000000..7ffa6d90a2 --- /dev/null +++ b/activesupport/lib/active_support/gzip.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "zlib" +require "stringio" + +module ActiveSupport + # A convenient wrapper for the zlib standard library that allows + # compression/decompression of strings with gzip. + # + # gzip = ActiveSupport::Gzip.compress('compress me!') + # # => "\x1F\x8B\b\x00o\x8D\xCDO\x00\x03K\xCE\xCF-(J-.V\xC8MU\x04\x00R>n\x83\f\x00\x00\x00" + # + # ActiveSupport::Gzip.decompress(gzip) + # # => "compress me!" + module Gzip + class Stream < StringIO + def initialize(*) + super + set_encoding "BINARY" + end + def close; rewind; end + end + + # Decompresses a gzipped string. + def self.decompress(source) + Zlib::GzipReader.wrap(StringIO.new(source), &:read) + end + + # Compresses a string using gzip. + def self.compress(source, level = Zlib::DEFAULT_COMPRESSION, strategy = Zlib::DEFAULT_STRATEGY) + output = Stream.new + gz = Zlib::GzipWriter.new(output, level, strategy) + gz.write(source) + gz.close + output.string + end + end +end diff --git a/activesupport/lib/active_support/hash_with_indifferent_access.rb b/activesupport/lib/active_support/hash_with_indifferent_access.rb new file mode 100644 index 0000000000..f1af76019a --- /dev/null +++ b/activesupport/lib/active_support/hash_with_indifferent_access.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/core_ext/hash/except" + +module ActiveSupport + # Implements a hash where keys <tt>:foo</tt> and <tt>"foo"</tt> are considered + # to be the same. + # + # rgb = ActiveSupport::HashWithIndifferentAccess.new + # + # rgb[:black] = '#000000' + # rgb[:black] # => '#000000' + # rgb['black'] # => '#000000' + # + # rgb['white'] = '#FFFFFF' + # rgb[:white] # => '#FFFFFF' + # rgb['white'] # => '#FFFFFF' + # + # Internally symbols are mapped to strings when used as keys in the entire + # writing interface (calling <tt>[]=</tt>, <tt>merge</tt>, etc). This + # mapping belongs to the public interface. For example, given: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new(a: 1) + # + # You are guaranteed that the key is returned as a string: + # + # hash.keys # => ["a"] + # + # Technically other types of keys are accepted: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new(a: 1) + # hash[0] = 0 + # hash # => {"a"=>1, 0=>0} + # + # but this class is intended for use cases where strings or symbols are the + # expected keys and it is convenient to understand both as the same. For + # example the +params+ hash in Ruby on Rails. + # + # Note that core extensions define <tt>Hash#with_indifferent_access</tt>: + # + # rgb = { black: '#000000', white: '#FFFFFF' }.with_indifferent_access + # + # which may be handy. + # + # To access this class outside of Rails, require the core extension with: + # + # require "active_support/core_ext/hash/indifferent_access" + # + # which will, in turn, require this file. + class HashWithIndifferentAccess < Hash + # Returns +true+ so that <tt>Array#extract_options!</tt> finds members of + # this class. + def extractable_options? + true + end + + def with_indifferent_access + dup + end + + def nested_under_indifferent_access + self + end + + def initialize(constructor = {}) + if constructor.respond_to?(:to_hash) + super() + update(constructor) + + hash = constructor.to_hash + self.default = hash.default if hash.default + self.default_proc = hash.default_proc if hash.default_proc + else + super(constructor) + end + end + + def self.[](*args) + new.merge!(Hash[*args]) + end + + alias_method :regular_writer, :[]= unless method_defined?(:regular_writer) + alias_method :regular_update, :update unless method_defined?(:regular_update) + + # Assigns a new value to the hash: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new + # hash[:key] = 'value' + # + # This value can be later fetched using either +:key+ or <tt>'key'</tt>. + def []=(key, value) + regular_writer(convert_key(key), convert_value(value, for: :assignment)) + end + + alias_method :store, :[]= + + # Updates the receiver in-place, merging in the hash passed as argument: + # + # hash_1 = ActiveSupport::HashWithIndifferentAccess.new + # hash_1[:key] = 'value' + # + # hash_2 = ActiveSupport::HashWithIndifferentAccess.new + # hash_2[:key] = 'New Value!' + # + # hash_1.update(hash_2) # => {"key"=>"New Value!"} + # + # The argument can be either an + # <tt>ActiveSupport::HashWithIndifferentAccess</tt> or a regular +Hash+. + # In either case the merge respects the semantics of indifferent access. + # + # If the argument is a regular hash with keys +:key+ and +"key"+ only one + # of the values end up in the receiver, but which one is unspecified. + # + # When given a block, the value for duplicated keys will be determined + # by the result of invoking the block with the duplicated key, the value + # in the receiver, and the value in +other_hash+. The rules for duplicated + # keys follow the semantics of indifferent access: + # + # hash_1[:key] = 10 + # hash_2['key'] = 12 + # hash_1.update(hash_2) { |key, old, new| old + new } # => {"key"=>22} + def update(other_hash) + if other_hash.is_a? HashWithIndifferentAccess + super(other_hash) + else + other_hash.to_hash.each_pair do |key, value| + if block_given? && key?(key) + value = yield(convert_key(key), self[key], value) + end + regular_writer(convert_key(key), convert_value(value)) + end + self + end + end + + alias_method :merge!, :update + + # Checks the hash for a key matching the argument passed in: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new + # hash['key'] = 'value' + # hash.key?(:key) # => true + # hash.key?('key') # => true + def key?(key) + super(convert_key(key)) + end + + alias_method :include?, :key? + alias_method :has_key?, :key? + alias_method :member?, :key? + + # Same as <tt>Hash#[]</tt> where the key passed as argument can be + # either a string or a symbol: + # + # counters = ActiveSupport::HashWithIndifferentAccess.new + # counters[:foo] = 1 + # + # counters['foo'] # => 1 + # counters[:foo] # => 1 + # counters[:zoo] # => nil + def [](key) + super(convert_key(key)) + end + + # Same as <tt>Hash#fetch</tt> where the key passed as argument can be + # either a string or a symbol: + # + # counters = ActiveSupport::HashWithIndifferentAccess.new + # counters[:foo] = 1 + # + # counters.fetch('foo') # => 1 + # counters.fetch(:bar, 0) # => 0 + # counters.fetch(:bar) { |key| 0 } # => 0 + # counters.fetch(:zoo) # => KeyError: key not found: "zoo" + def fetch(key, *extras) + super(convert_key(key), *extras) + end + + # Same as <tt>Hash#dig</tt> where the key passed as argument can be + # either a string or a symbol: + # + # counters = ActiveSupport::HashWithIndifferentAccess.new + # counters[:foo] = { bar: 1 } + # + # counters.dig('foo', 'bar') # => 1 + # counters.dig(:foo, :bar) # => 1 + # counters.dig(:zoo) # => nil + def dig(*args) + args[0] = convert_key(args[0]) if args.size > 0 + super(*args) + end + + # Same as <tt>Hash#default</tt> where the key passed as argument can be + # either a string or a symbol: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new(1) + # hash.default # => 1 + # + # hash = ActiveSupport::HashWithIndifferentAccess.new { |hash, key| key } + # hash.default # => nil + # hash.default('foo') # => 'foo' + # hash.default(:foo) # => 'foo' + def default(*args) + super(*args.map { |arg| convert_key(arg) }) + end + + # Returns an array of the values at the specified indices: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new + # hash[:a] = 'x' + # hash[:b] = 'y' + # hash.values_at('a', 'b') # => ["x", "y"] + def values_at(*indices) + indices.collect { |key| self[convert_key(key)] } + end + + # Returns an array of the values at the specified indices, but also + # raises an exception when one of the keys can't be found. + # + # hash = ActiveSupport::HashWithIndifferentAccess.new + # hash[:a] = 'x' + # hash[:b] = 'y' + # hash.fetch_values('a', 'b') # => ["x", "y"] + # hash.fetch_values('a', 'c') { |key| 'z' } # => ["x", "z"] + # hash.fetch_values('a', 'c') # => KeyError: key not found: "c" + def fetch_values(*indices, &block) + indices.collect { |key| fetch(key, &block) } + end + + # Returns a shallow copy of the hash. + # + # hash = ActiveSupport::HashWithIndifferentAccess.new({ a: { b: 'b' } }) + # dup = hash.dup + # dup[:a][:c] = 'c' + # + # hash[:a][:c] # => "c" + # dup[:a][:c] # => "c" + def dup + self.class.new(self).tap do |new_hash| + set_defaults(new_hash) + end + end + + # This method has the same semantics of +update+, except it does not + # modify the receiver but rather returns a new hash with indifferent + # access with the result of the merge. + def merge(hash, &block) + dup.update(hash, &block) + end + + # Like +merge+ but the other way around: Merges the receiver into the + # argument and returns a new hash with indifferent access as result: + # + # hash = ActiveSupport::HashWithIndifferentAccess.new + # hash['a'] = nil + # hash.reverse_merge(a: 0, b: 1) # => {"a"=>nil, "b"=>1} + def reverse_merge(other_hash) + super(self.class.new(other_hash)) + end + alias_method :with_defaults, :reverse_merge + + # Same semantics as +reverse_merge+ but modifies the receiver in-place. + def reverse_merge!(other_hash) + super(self.class.new(other_hash)) + end + alias_method :with_defaults!, :reverse_merge! + + # Replaces the contents of this hash with other_hash. + # + # h = { "a" => 100, "b" => 200 } + # h.replace({ "c" => 300, "d" => 400 }) # => {"c"=>300, "d"=>400} + def replace(other_hash) + super(self.class.new(other_hash)) + end + + # Removes the specified key from the hash. + def delete(key) + super(convert_key(key)) + end + + alias_method :without, :except + + def stringify_keys!; self end + def deep_stringify_keys!; self end + def stringify_keys; dup end + def deep_stringify_keys; dup end + undef :symbolize_keys! + undef :deep_symbolize_keys! + def symbolize_keys; to_hash.symbolize_keys! end + alias_method :to_options, :symbolize_keys + def deep_symbolize_keys; to_hash.deep_symbolize_keys! end + def to_options!; self end + + def select(*args, &block) + return to_enum(:select) unless block_given? + dup.tap { |hash| hash.select!(*args, &block) } + end + + def reject(*args, &block) + return to_enum(:reject) unless block_given? + dup.tap { |hash| hash.reject!(*args, &block) } + end + + def transform_values(*args, &block) + return to_enum(:transform_values) unless block_given? + dup.tap { |hash| hash.transform_values!(*args, &block) } + end + + def transform_keys(*args, &block) + return to_enum(:transform_keys) unless block_given? + dup.tap { |hash| hash.transform_keys!(*args, &block) } + end + + def transform_keys! + return enum_for(:transform_keys!) { size } unless block_given? + keys.each do |key| + self[yield(key)] = delete(key) + end + self + end + + def slice(*keys) + keys.map! { |key| convert_key(key) } + self.class.new(super) + end + + def slice!(*keys) + keys.map! { |key| convert_key(key) } + super + end + + def compact + dup.tap(&:compact!) + end + + # Convert to a regular hash with string keys. + def to_hash + _new_hash = Hash.new + set_defaults(_new_hash) + + each do |key, value| + _new_hash[key] = convert_value(value, for: :to_hash) + end + _new_hash + end + + private + def convert_key(key) # :doc: + key.kind_of?(Symbol) ? key.to_s : key + end + + def convert_value(value, options = {}) # :doc: + if value.is_a? Hash + if options[:for] == :to_hash + value.to_hash + else + value.nested_under_indifferent_access + end + elsif value.is_a?(Array) + if options[:for] != :assignment || value.frozen? + value = value.dup + end + value.map! { |e| convert_value(e, options) } + else + value + end + end + + def set_defaults(target) # :doc: + if default_proc + target.default_proc = default_proc.dup + else + target.default = default + end + end + end +end + +# :stopdoc: + +HashWithIndifferentAccess = ActiveSupport::HashWithIndifferentAccess diff --git a/activesupport/lib/active_support/i18n.rb b/activesupport/lib/active_support/i18n.rb new file mode 100644 index 0000000000..39dab1cc71 --- /dev/null +++ b/activesupport/lib/active_support/i18n.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/deep_merge" +require "active_support/core_ext/hash/except" +require "active_support/core_ext/hash/slice" +begin + require "i18n" +rescue LoadError => e + $stderr.puts "The i18n gem is not available. Please add it to your Gemfile and run bundle install" + raise e +end +require "active_support/lazy_load_hooks" + +ActiveSupport.run_load_hooks(:i18n) +I18n.load_path << File.expand_path("locale/en.yml", __dir__) +I18n.load_path << File.expand_path("locale/en.rb", __dir__) diff --git a/activesupport/lib/active_support/i18n_railtie.rb b/activesupport/lib/active_support/i18n_railtie.rb new file mode 100644 index 0000000000..584930e413 --- /dev/null +++ b/activesupport/lib/active_support/i18n_railtie.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/core_ext/array/wrap" + +# :enddoc: + +module I18n + class Railtie < Rails::Railtie + config.i18n = ActiveSupport::OrderedOptions.new + config.i18n.railties_load_path = [] + config.i18n.load_path = [] + config.i18n.fallbacks = ActiveSupport::OrderedOptions.new + + # Set the i18n configuration after initialization since a lot of + # configuration is still usually done in application initializers. + config.after_initialize do |app| + I18n::Railtie.initialize_i18n(app) + end + + # Trigger i18n config before any eager loading has happened + # so it's ready if any classes require it when eager loaded. + config.before_eager_load do |app| + I18n::Railtie.initialize_i18n(app) + end + + @i18n_inited = false + + # Setup i18n configuration. + def self.initialize_i18n(app) + return if @i18n_inited + + fallbacks = app.config.i18n.delete(:fallbacks) + + # Avoid issues with setting the default_locale by disabling available locales + # check while configuring. + enforce_available_locales = app.config.i18n.delete(:enforce_available_locales) + enforce_available_locales = I18n.enforce_available_locales if enforce_available_locales.nil? + I18n.enforce_available_locales = false + + reloadable_paths = [] + app.config.i18n.each do |setting, value| + case setting + when :railties_load_path + reloadable_paths = value + app.config.i18n.load_path.unshift(*value.flat_map(&:existent)) + when :load_path + I18n.load_path += value + else + I18n.send("#{setting}=", value) + end + end + + init_fallbacks(fallbacks) if fallbacks && validate_fallbacks(fallbacks) + + # Restore available locales check so it will take place from now on. + I18n.enforce_available_locales = enforce_available_locales + + directories = watched_dirs_with_extensions(reloadable_paths) + reloader = app.config.file_watcher.new(I18n.load_path.dup, directories) do + I18n.load_path.keep_if { |p| File.exist?(p) } + I18n.load_path |= reloadable_paths.flat_map(&:existent) + + I18n.reload! + end + + app.reloaders << reloader + app.reloader.to_run do + reloader.execute_if_updated { require_unload_lock! } + end + reloader.execute + + @i18n_inited = true + end + + def self.include_fallbacks_module + I18n.backend.class.include(I18n::Backend::Fallbacks) + end + + def self.init_fallbacks(fallbacks) + include_fallbacks_module + + args = \ + case fallbacks + when ActiveSupport::OrderedOptions + [*(fallbacks[:defaults] || []) << fallbacks[:map]].compact + when Hash, Array + Array.wrap(fallbacks) + else # TrueClass + [I18n.default_locale] + end + + if args.empty? || args.first.is_a?(Hash) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + Using I18n fallbacks with an empty `defaults` sets the defaults to + include the `default_locale`. This behavior will change in Rails 6.1. + If you desire the default locale to be included in the defaults, please + explicitly configure it with `config.i18n.fallbacks.defaults = + [I18n.default_locale]` or `config.i18n.fallbacks = [I18n.default_locale, + {...}]` + MSG + args.unshift I18n.default_locale + end + + I18n.fallbacks = I18n::Locale::Fallbacks.new(*args) + end + + def self.validate_fallbacks(fallbacks) + case fallbacks + when ActiveSupport::OrderedOptions + !fallbacks.empty? + when TrueClass, Array, Hash + true + else + raise "Unexpected fallback type #{fallbacks.inspect}" + end + end + + def self.watched_dirs_with_extensions(paths) + paths.each_with_object({}) do |path, result| + result[path.absolute_current] = path.extensions + end + end + end +end diff --git a/activesupport/lib/active_support/inflections.rb b/activesupport/lib/active_support/inflections.rb new file mode 100644 index 0000000000..baf1cb3038 --- /dev/null +++ b/activesupport/lib/active_support/inflections.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "active_support/inflector/inflections" + +#-- +# Defines the standard inflection rules. These are the starting point for +# new projects and are not considered complete. The current set of inflection +# rules is frozen. This means, we do not change them to become more complete. +# This is a safety measure to keep existing applications from breaking. +#++ +module ActiveSupport + Inflector.inflections(:en) do |inflect| + inflect.plural(/$/, "s") + inflect.plural(/s$/i, "s") + inflect.plural(/^(ax|test)is$/i, '\1es') + inflect.plural(/(octop|vir)us$/i, '\1i') + inflect.plural(/(octop|vir)i$/i, '\1i') + inflect.plural(/(alias|status)$/i, '\1es') + inflect.plural(/(bu)s$/i, '\1ses') + inflect.plural(/(buffal|tomat)o$/i, '\1oes') + inflect.plural(/([ti])um$/i, '\1a') + inflect.plural(/([ti])a$/i, '\1a') + inflect.plural(/sis$/i, "ses") + inflect.plural(/(?:([^f])fe|([lr])f)$/i, '\1\2ves') + inflect.plural(/(hive)$/i, '\1s') + inflect.plural(/([^aeiouy]|qu)y$/i, '\1ies') + inflect.plural(/(x|ch|ss|sh)$/i, '\1es') + inflect.plural(/(matr|vert|ind)(?:ix|ex)$/i, '\1ices') + inflect.plural(/^(m|l)ouse$/i, '\1ice') + inflect.plural(/^(m|l)ice$/i, '\1ice') + inflect.plural(/^(ox)$/i, '\1en') + inflect.plural(/^(oxen)$/i, '\1') + inflect.plural(/(quiz)$/i, '\1zes') + + inflect.singular(/s$/i, "") + inflect.singular(/(ss)$/i, '\1') + inflect.singular(/(n)ews$/i, '\1ews') + inflect.singular(/([ti])a$/i, '\1um') + inflect.singular(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, '\1sis') + inflect.singular(/(^analy)(sis|ses)$/i, '\1sis') + inflect.singular(/([^f])ves$/i, '\1fe') + inflect.singular(/(hive)s$/i, '\1') + inflect.singular(/(tive)s$/i, '\1') + inflect.singular(/([lr])ves$/i, '\1f') + inflect.singular(/([^aeiouy]|qu)ies$/i, '\1y') + inflect.singular(/(s)eries$/i, '\1eries') + inflect.singular(/(m)ovies$/i, '\1ovie') + inflect.singular(/(x|ch|ss|sh)es$/i, '\1') + inflect.singular(/^(m|l)ice$/i, '\1ouse') + inflect.singular(/(bus)(es)?$/i, '\1') + inflect.singular(/(o)es$/i, '\1') + inflect.singular(/(shoe)s$/i, '\1') + inflect.singular(/(cris|test)(is|es)$/i, '\1is') + inflect.singular(/^(a)x[ie]s$/i, '\1xis') + inflect.singular(/(octop|vir)(us|i)$/i, '\1us') + inflect.singular(/(alias|status)(es)?$/i, '\1') + inflect.singular(/^(ox)en/i, '\1') + inflect.singular(/(vert|ind)ices$/i, '\1ex') + inflect.singular(/(matr)ices$/i, '\1ix') + inflect.singular(/(quiz)zes$/i, '\1') + inflect.singular(/(database)s$/i, '\1') + + inflect.irregular("person", "people") + inflect.irregular("man", "men") + inflect.irregular("child", "children") + inflect.irregular("sex", "sexes") + inflect.irregular("move", "moves") + inflect.irregular("zombie", "zombies") + + inflect.uncountable(%w(equipment information rice money species series fish sheep jeans police)) + end +end diff --git a/activesupport/lib/active_support/inflector.rb b/activesupport/lib/active_support/inflector.rb new file mode 100644 index 0000000000..d77f04c9c5 --- /dev/null +++ b/activesupport/lib/active_support/inflector.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# in case active_support/inflector is required without the rest of active_support +require "active_support/inflector/inflections" +require "active_support/inflector/transliterate" +require "active_support/inflector/methods" + +require "active_support/inflections" +require "active_support/core_ext/string/inflections" diff --git a/activesupport/lib/active_support/inflector/inflections.rb b/activesupport/lib/active_support/inflector/inflections.rb new file mode 100644 index 0000000000..fa087c4dd6 --- /dev/null +++ b/activesupport/lib/active_support/inflector/inflections.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require "concurrent/map" +require "active_support/i18n" +require "active_support/deprecation" + +module ActiveSupport + module Inflector + extend self + + # A singleton instance of this class is yielded by Inflector.inflections, + # which can then be used to specify additional inflection rules. If passed + # an optional locale, rules for other languages can be specified. The + # default locale is <tt>:en</tt>. Only rules for English are provided. + # + # ActiveSupport::Inflector.inflections(:en) do |inflect| + # inflect.plural /^(ox)$/i, '\1\2en' + # inflect.singular /^(ox)en/i, '\1' + # + # inflect.irregular 'octopus', 'octopi' + # + # inflect.uncountable 'equipment' + # end + # + # New rules are added at the top. So in the example above, the irregular + # rule for octopus will now be the first of the pluralization and + # singularization rules that is runs. This guarantees that your rules run + # before any of the rules that may already have been loaded. + class Inflections + @__instance__ = Concurrent::Map.new + + class Uncountables < Array + def initialize + @regex_array = [] + super + end + + def delete(entry) + super entry + @regex_array.delete(to_regex(entry)) + end + + def <<(*word) + add(word) + end + + def add(words) + words = words.flatten.map(&:downcase) + concat(words) + @regex_array += words.map { |word| to_regex(word) } + self + end + + def uncountable?(str) + @regex_array.any? { |regex| regex.match? str } + end + + private + def to_regex(string) + /\b#{::Regexp.escape(string)}\Z/i + end + end + + def self.instance(locale = :en) + @__instance__[locale] ||= new + end + + attr_reader :plurals, :singulars, :uncountables, :humans, :acronyms, :acronym_regex + deprecate :acronym_regex + + attr_reader :acronyms_camelize_regex, :acronyms_underscore_regex # :nodoc: + + def initialize + @plurals, @singulars, @uncountables, @humans, @acronyms = [], [], Uncountables.new, [], {} + define_acronym_regex_patterns + end + + # Private, for the test suite. + def initialize_dup(orig) # :nodoc: + %w(plurals singulars uncountables humans acronyms).each do |scope| + instance_variable_set("@#{scope}", orig.send(scope).dup) + end + define_acronym_regex_patterns + end + + # Specifies a new acronym. An acronym must be specified as it will appear + # in a camelized string. An underscore string that contains the acronym + # will retain the acronym when passed to +camelize+, +humanize+, or + # +titleize+. A camelized string that contains the acronym will maintain + # the acronym when titleized or humanized, and will convert the acronym + # into a non-delimited single lowercase word when passed to +underscore+. + # + # acronym 'HTML' + # titleize 'html' # => 'HTML' + # camelize 'html' # => 'HTML' + # underscore 'MyHTML' # => 'my_html' + # + # The acronym, however, must occur as a delimited unit and not be part of + # another word for conversions to recognize it: + # + # acronym 'HTTP' + # camelize 'my_http_delimited' # => 'MyHTTPDelimited' + # camelize 'https' # => 'Https', not 'HTTPs' + # underscore 'HTTPS' # => 'http_s', not 'https' + # + # acronym 'HTTPS' + # camelize 'https' # => 'HTTPS' + # underscore 'HTTPS' # => 'https' + # + # Note: Acronyms that are passed to +pluralize+ will no longer be + # recognized, since the acronym will not occur as a delimited unit in the + # pluralized result. To work around this, you must specify the pluralized + # form as an acronym as well: + # + # acronym 'API' + # camelize(pluralize('api')) # => 'Apis' + # + # acronym 'APIs' + # camelize(pluralize('api')) # => 'APIs' + # + # +acronym+ may be used to specify any word that contains an acronym or + # otherwise needs to maintain a non-standard capitalization. The only + # restriction is that the word must begin with a capital letter. + # + # acronym 'RESTful' + # underscore 'RESTful' # => 'restful' + # underscore 'RESTfulController' # => 'restful_controller' + # titleize 'RESTfulController' # => 'RESTful Controller' + # camelize 'restful' # => 'RESTful' + # camelize 'restful_controller' # => 'RESTfulController' + # + # acronym 'McDonald' + # underscore 'McDonald' # => 'mcdonald' + # camelize 'mcdonald' # => 'McDonald' + def acronym(word) + @acronyms[word.downcase] = word + define_acronym_regex_patterns + end + + # Specifies a new pluralization rule and its replacement. The rule can + # either be a string or a regular expression. The replacement should + # always be a string that may include references to the matched data from + # the rule. + def plural(rule, replacement) + @uncountables.delete(rule) if rule.is_a?(String) + @uncountables.delete(replacement) + @plurals.prepend([rule, replacement]) + end + + # Specifies a new singularization rule and its replacement. The rule can + # either be a string or a regular expression. The replacement should + # always be a string that may include references to the matched data from + # the rule. + def singular(rule, replacement) + @uncountables.delete(rule) if rule.is_a?(String) + @uncountables.delete(replacement) + @singulars.prepend([rule, replacement]) + end + + # Specifies a new irregular that applies to both pluralization and + # singularization at the same time. This can only be used for strings, not + # regular expressions. You simply pass the irregular in singular and + # plural form. + # + # irregular 'octopus', 'octopi' + # irregular 'person', 'people' + def irregular(singular, plural) + @uncountables.delete(singular) + @uncountables.delete(plural) + + s0 = singular[0] + srest = singular[1..-1] + + p0 = plural[0] + prest = plural[1..-1] + + if s0.upcase == p0.upcase + plural(/(#{s0})#{srest}$/i, '\1' + prest) + plural(/(#{p0})#{prest}$/i, '\1' + prest) + + singular(/(#{s0})#{srest}$/i, '\1' + srest) + singular(/(#{p0})#{prest}$/i, '\1' + srest) + else + plural(/#{s0.upcase}(?i)#{srest}$/, p0.upcase + prest) + plural(/#{s0.downcase}(?i)#{srest}$/, p0.downcase + prest) + plural(/#{p0.upcase}(?i)#{prest}$/, p0.upcase + prest) + plural(/#{p0.downcase}(?i)#{prest}$/, p0.downcase + prest) + + singular(/#{s0.upcase}(?i)#{srest}$/, s0.upcase + srest) + singular(/#{s0.downcase}(?i)#{srest}$/, s0.downcase + srest) + singular(/#{p0.upcase}(?i)#{prest}$/, s0.upcase + srest) + singular(/#{p0.downcase}(?i)#{prest}$/, s0.downcase + srest) + end + end + + # Specifies words that are uncountable and should not be inflected. + # + # uncountable 'money' + # uncountable 'money', 'information' + # uncountable %w( money information rice ) + def uncountable(*words) + @uncountables.add(words) + end + + # Specifies a humanized form of a string by a regular expression rule or + # by a string mapping. When using a regular expression based replacement, + # the normal humanize formatting is called after the replacement. When a + # string is used, the human form should be specified as desired (example: + # 'The name', not 'the_name'). + # + # human /_cnt$/i, '\1_count' + # human 'legacy_col_person_name', 'Name' + def human(rule, replacement) + @humans.prepend([rule, replacement]) + end + + # Clears the loaded inflections within a given scope (default is + # <tt>:all</tt>). Give the scope as a symbol of the inflection type, the + # options are: <tt>:plurals</tt>, <tt>:singulars</tt>, <tt>:uncountables</tt>, + # <tt>:humans</tt>. + # + # clear :all + # clear :plurals + def clear(scope = :all) + case scope + when :all + @plurals, @singulars, @uncountables, @humans = [], [], Uncountables.new, [] + else + instance_variable_set "@#{scope}", [] + end + end + + private + + def define_acronym_regex_patterns + @acronym_regex = @acronyms.empty? ? /(?=a)b/ : /#{@acronyms.values.join("|")}/ + @acronyms_camelize_regex = /^(?:#{@acronym_regex}(?=\b|[A-Z_])|\w)/ + @acronyms_underscore_regex = /(?:(?<=([A-Za-z\d]))|\b)(#{@acronym_regex})(?=\b|[^a-z])/ + end + end + + # Yields a singleton instance of Inflector::Inflections so you can specify + # additional inflector rules. If passed an optional locale, rules for other + # languages can be specified. If not specified, defaults to <tt>:en</tt>. + # Only rules for English are provided. + # + # ActiveSupport::Inflector.inflections(:en) do |inflect| + # inflect.uncountable 'rails' + # end + def inflections(locale = :en) + if block_given? + yield Inflections.instance(locale) + else + Inflections.instance(locale) + end + end + end +end diff --git a/activesupport/lib/active_support/inflector/methods.rb b/activesupport/lib/active_support/inflector/methods.rb new file mode 100644 index 0000000000..1af9833d46 --- /dev/null +++ b/activesupport/lib/active_support/inflector/methods.rb @@ -0,0 +1,396 @@ +# frozen_string_literal: true + +require "active_support/inflections" + +module ActiveSupport + # The Inflector transforms words from singular to plural, class names to table + # names, modularized class names to ones without, and class names to foreign + # keys. The default inflections for pluralization, singularization, and + # uncountable words are kept in inflections.rb. + # + # The Rails core team has stated patches for the inflections library will not + # be accepted in order to avoid breaking legacy applications which may be + # relying on errant inflections. If you discover an incorrect inflection and + # require it for your application or wish to define rules for languages other + # than English, please correct or add them yourself (explained below). + module Inflector + extend self + + # Returns the plural form of the word in the string. + # + # If passed an optional +locale+ parameter, the word will be + # pluralized using rules defined for that language. By default, + # this parameter is set to <tt>:en</tt>. + # + # pluralize('post') # => "posts" + # pluralize('octopus') # => "octopi" + # pluralize('sheep') # => "sheep" + # pluralize('words') # => "words" + # pluralize('CamelOctopus') # => "CamelOctopi" + # pluralize('ley', :es) # => "leyes" + def pluralize(word, locale = :en) + apply_inflections(word, inflections(locale).plurals, locale) + end + + # The reverse of #pluralize, returns the singular form of a word in a + # string. + # + # If passed an optional +locale+ parameter, the word will be + # singularized using rules defined for that language. By default, + # this parameter is set to <tt>:en</tt>. + # + # singularize('posts') # => "post" + # singularize('octopi') # => "octopus" + # singularize('sheep') # => "sheep" + # singularize('word') # => "word" + # singularize('CamelOctopi') # => "CamelOctopus" + # singularize('leyes', :es) # => "ley" + def singularize(word, locale = :en) + apply_inflections(word, inflections(locale).singulars, locale) + end + + # Converts strings to UpperCamelCase. + # If the +uppercase_first_letter+ parameter is set to false, then produces + # lowerCamelCase. + # + # Also converts '/' to '::' which is useful for converting + # paths to namespaces. + # + # camelize('active_model') # => "ActiveModel" + # camelize('active_model', false) # => "activeModel" + # camelize('active_model/errors') # => "ActiveModel::Errors" + # camelize('active_model/errors', false) # => "activeModel::Errors" + # + # As a rule of thumb you can think of +camelize+ as the inverse of + # #underscore, though there are cases where that does not hold: + # + # camelize(underscore('SSLError')) # => "SslError" + def camelize(term, uppercase_first_letter = true) + string = term.to_s + if uppercase_first_letter + string = string.sub(/^[a-z\d]*/) { |match| inflections.acronyms[match] || match.capitalize } + else + string = string.sub(inflections.acronyms_camelize_regex) { |match| match.downcase } + end + string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{$1}#{inflections.acronyms[$2] || $2.capitalize}" } + string.gsub!("/", "::") + string + end + + # Makes an underscored, lowercase form from the expression in the string. + # + # Changes '::' to '/' to convert namespaces to paths. + # + # underscore('ActiveModel') # => "active_model" + # underscore('ActiveModel::Errors') # => "active_model/errors" + # + # As a rule of thumb you can think of +underscore+ as the inverse of + # #camelize, though there are cases where that does not hold: + # + # camelize(underscore('SSLError')) # => "SslError" + def underscore(camel_cased_word) + return camel_cased_word unless /[A-Z-]|::/.match?(camel_cased_word) + word = camel_cased_word.to_s.gsub("::", "/") + word.gsub!(inflections.acronyms_underscore_regex) { "#{$1 && '_' }#{$2.downcase}" } + word.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2') + word.gsub!(/([a-z\d])([A-Z])/, '\1_\2') + word.tr!("-", "_") + word.downcase! + word + end + + # Tweaks an attribute name for display to end users. + # + # Specifically, performs these transformations: + # + # * Applies human inflection rules to the argument. + # * Deletes leading underscores, if any. + # * Removes a "_id" suffix if present. + # * Replaces underscores with spaces, if any. + # * Downcases all words except acronyms. + # * Capitalizes the first word. + # The capitalization of the first word can be turned off by setting the + # +:capitalize+ option to false (default is true). + # + # The trailing '_id' can be kept and capitalized by setting the + # optional parameter +keep_id_suffix+ to true (default is false). + # + # humanize('employee_salary') # => "Employee salary" + # humanize('author_id') # => "Author" + # humanize('author_id', capitalize: false) # => "author" + # humanize('_id') # => "Id" + # humanize('author_id', keep_id_suffix: true) # => "Author Id" + # + # If "SSL" was defined to be an acronym: + # + # humanize('ssl_error') # => "SSL error" + # + def humanize(lower_case_and_underscored_word, capitalize: true, keep_id_suffix: false) + result = lower_case_and_underscored_word.to_s.dup + + inflections.humans.each { |(rule, replacement)| break if result.sub!(rule, replacement) } + + result.sub!(/\A_+/, "") + unless keep_id_suffix + result.sub!(/_id\z/, "") + end + result.tr!("_", " ") + + result.gsub!(/([a-z\d]*)/i) do |match| + "#{inflections.acronyms[match.downcase] || match.downcase}" + end + + if capitalize + result.sub!(/\A\w/) { |match| match.upcase } + end + + result + end + + # Converts just the first character to uppercase. + # + # upcase_first('what a Lovely Day') # => "What a Lovely Day" + # upcase_first('w') # => "W" + # upcase_first('') # => "" + def upcase_first(string) + string.length > 0 ? string[0].upcase.concat(string[1..-1]) : "" + end + + # Capitalizes all the words and replaces some characters in the string to + # create a nicer looking title. +titleize+ is meant for creating pretty + # output. It is not used in the Rails internals. + # + # The trailing '_id','Id'.. can be kept and capitalized by setting the + # optional parameter +keep_id_suffix+ to true. + # By default, this parameter is false. + # + # +titleize+ is also aliased as +titlecase+. + # + # titleize('man from the boondocks') # => "Man From The Boondocks" + # titleize('x-men: the last stand') # => "X Men: The Last Stand" + # titleize('TheManWithoutAPast') # => "The Man Without A Past" + # titleize('raiders_of_the_lost_ark') # => "Raiders Of The Lost Ark" + # titleize('string_ending_with_id', keep_id_suffix: true) # => "String Ending With Id" + def titleize(word, keep_id_suffix: false) + humanize(underscore(word), keep_id_suffix: keep_id_suffix).gsub(/\b(?<!\w['’`])[a-z]/) do |match| + match.capitalize + end + end + + # Creates the name of a table like Rails does for models to table names. + # This method uses the #pluralize method on the last word in the string. + # + # tableize('RawScaledScorer') # => "raw_scaled_scorers" + # tableize('ham_and_egg') # => "ham_and_eggs" + # tableize('fancyCategory') # => "fancy_categories" + def tableize(class_name) + pluralize(underscore(class_name)) + end + + # Creates a class name from a plural table name like Rails does for table + # names to models. Note that this returns a string and not a Class (To + # convert to an actual class follow +classify+ with #constantize). + # + # classify('ham_and_eggs') # => "HamAndEgg" + # classify('posts') # => "Post" + # + # Singular names are not handled correctly: + # + # classify('calculus') # => "Calculus" + def classify(table_name) + # strip out any leading schema name + camelize(singularize(table_name.to_s.sub(/.*\./, ""))) + end + + # Replaces underscores with dashes in the string. + # + # dasherize('puni_puni') # => "puni-puni" + def dasherize(underscored_word) + underscored_word.tr("_", "-") + end + + # Removes the module part from the expression in the string. + # + # demodulize('ActiveSupport::Inflector::Inflections') # => "Inflections" + # demodulize('Inflections') # => "Inflections" + # demodulize('::Inflections') # => "Inflections" + # demodulize('') # => "" + # + # See also #deconstantize. + def demodulize(path) + path = path.to_s + if i = path.rindex("::") + path[(i + 2)..-1] + else + path + end + end + + # Removes the rightmost segment from the constant expression in the string. + # + # deconstantize('Net::HTTP') # => "Net" + # deconstantize('::Net::HTTP') # => "::Net" + # deconstantize('String') # => "" + # deconstantize('::String') # => "" + # deconstantize('') # => "" + # + # See also #demodulize. + def deconstantize(path) + path.to_s[0, path.rindex("::") || 0] # implementation based on the one in facets' Module#spacename + end + + # Creates a foreign key name from a class name. + # +separate_class_name_and_id_with_underscore+ sets whether + # the method should put '_' between the name and 'id'. + # + # foreign_key('Message') # => "message_id" + # foreign_key('Message', false) # => "messageid" + # foreign_key('Admin::Post') # => "post_id" + def foreign_key(class_name, separate_class_name_and_id_with_underscore = true) + underscore(demodulize(class_name)) + (separate_class_name_and_id_with_underscore ? "_id" : "id") + end + + # Tries to find a constant with the name specified in the argument string. + # + # constantize('Module') # => Module + # constantize('Foo::Bar') # => Foo::Bar + # + # The name is assumed to be the one of a top-level constant, no matter + # whether it starts with "::" or not. No lexical context is taken into + # account: + # + # C = 'outside' + # module M + # C = 'inside' + # C # => 'inside' + # constantize('C') # => 'outside', same as ::C + # end + # + # NameError is raised when the name is not in CamelCase or the constant is + # unknown. + def constantize(camel_cased_word) + names = camel_cased_word.split("::") + + # Trigger a built-in NameError exception including the ill-formed constant in the message. + Object.const_get(camel_cased_word) if names.empty? + + # Remove the first blank element in case of '::ClassName' notation. + names.shift if names.size > 1 && names.first.empty? + + names.inject(Object) do |constant, name| + if constant == Object + constant.const_get(name) + else + candidate = constant.const_get(name) + next candidate if constant.const_defined?(name, false) + next candidate unless Object.const_defined?(name) + + # Go down the ancestors to check if it is owned directly. The check + # stops when we reach Object or the end of ancestors tree. + constant = constant.ancestors.inject(constant) do |const, ancestor| + break const if ancestor == Object + break ancestor if ancestor.const_defined?(name, false) + const + end + + # owner is in Object, so raise + constant.const_get(name, false) + end + end + end + + # Tries to find a constant with the name specified in the argument string. + # + # safe_constantize('Module') # => Module + # safe_constantize('Foo::Bar') # => Foo::Bar + # + # The name is assumed to be the one of a top-level constant, no matter + # whether it starts with "::" or not. No lexical context is taken into + # account: + # + # C = 'outside' + # module M + # C = 'inside' + # C # => 'inside' + # safe_constantize('C') # => 'outside', same as ::C + # end + # + # +nil+ is returned when the name is not in CamelCase or the constant (or + # part of it) is unknown. + # + # safe_constantize('blargle') # => nil + # safe_constantize('UnknownModule') # => nil + # safe_constantize('UnknownModule::Foo::Bar') # => nil + def safe_constantize(camel_cased_word) + constantize(camel_cased_word) + rescue NameError => e + raise if e.name && !(camel_cased_word.to_s.split("::").include?(e.name.to_s) || + e.name.to_s == camel_cased_word.to_s) + rescue ArgumentError => e + raise unless /not missing constant #{const_regexp(camel_cased_word)}!$/.match?(e.message) + end + + # Returns the suffix that should be added to a number to denote the position + # in an ordered sequence such as 1st, 2nd, 3rd, 4th. + # + # ordinal(1) # => "st" + # ordinal(2) # => "nd" + # ordinal(1002) # => "nd" + # ordinal(1003) # => "rd" + # ordinal(-11) # => "th" + # ordinal(-1021) # => "st" + def ordinal(number) + I18n.translate("number.nth.ordinals", number: number) + end + + # Turns a number into an ordinal string used to denote the position in an + # ordered sequence such as 1st, 2nd, 3rd, 4th. + # + # ordinalize(1) # => "1st" + # ordinalize(2) # => "2nd" + # ordinalize(1002) # => "1002nd" + # ordinalize(1003) # => "1003rd" + # ordinalize(-11) # => "-11th" + # ordinalize(-1021) # => "-1021st" + def ordinalize(number) + I18n.translate("number.nth.ordinalized", number: number) + end + + private + + # Mounts a regular expression, returned as a string to ease interpolation, + # that will match part by part the given constant. + # + # const_regexp("Foo::Bar::Baz") # => "Foo(::Bar(::Baz)?)?" + # const_regexp("::") # => "::" + def const_regexp(camel_cased_word) + parts = camel_cased_word.split("::") + + return Regexp.escape(camel_cased_word) if parts.blank? + + last = parts.pop + + parts.reverse.inject(last) do |acc, part| + part.empty? ? acc : "#{part}(::#{acc})?" + end + end + + # Applies inflection rules for +singularize+ and +pluralize+. + # + # If passed an optional +locale+ parameter, the uncountables will be + # found for that locale. + # + # apply_inflections('post', inflections.plurals, :en) # => "posts" + # apply_inflections('posts', inflections.singulars, :en) # => "post" + def apply_inflections(word, rules, locale = :en) + result = word.to_s.dup + + if word.empty? || inflections(locale).uncountables.uncountable?(result) + result + else + rules.each { |(rule, replacement)| break if result.sub!(rule, replacement) } + result + end + end + end +end diff --git a/activesupport/lib/active_support/inflector/transliterate.rb b/activesupport/lib/active_support/inflector/transliterate.rb new file mode 100644 index 0000000000..0d2a17970f --- /dev/null +++ b/activesupport/lib/active_support/inflector/transliterate.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string/multibyte" +require "active_support/i18n" + +module ActiveSupport + module Inflector + # Replaces non-ASCII characters with an ASCII approximation, or if none + # exists, a replacement character which defaults to "?". + # + # transliterate('Ærøskøbing') + # # => "AEroskobing" + # + # Default approximations are provided for Western/Latin characters, + # e.g, "ø", "ñ", "é", "ß", etc. + # + # This method is I18n aware, so you can set up custom approximations for a + # locale. This can be useful, for example, to transliterate German's "ü" + # and "ö" to "ue" and "oe", or to add support for transliterating Russian + # to ASCII. + # + # In order to make your custom transliterations available, you must set + # them as the <tt>i18n.transliterate.rule</tt> i18n key: + # + # # Store the transliterations in locales/de.yml + # i18n: + # transliterate: + # rule: + # ü: "ue" + # ö: "oe" + # + # # Or set them using Ruby + # I18n.backend.store_translations(:de, i18n: { + # transliterate: { + # rule: { + # 'ü' => 'ue', + # 'ö' => 'oe' + # } + # } + # }) + # + # The value for <tt>i18n.transliterate.rule</tt> can be a simple Hash that + # maps characters to ASCII approximations as shown above, or, for more + # complex requirements, a Proc: + # + # I18n.backend.store_translations(:de, i18n: { + # transliterate: { + # rule: ->(string) { MyTransliterator.transliterate(string) } + # } + # }) + # + # Now you can have different transliterations for each locale: + # + # I18n.locale = :en + # transliterate('Jürgen') + # # => "Jurgen" + # + # I18n.locale = :de + # transliterate('Jürgen') + # # => "Juergen" + def transliterate(string, replacement = "?") + raise ArgumentError, "Can only transliterate strings. Received #{string.class.name}" unless string.is_a?(String) + + I18n.transliterate( + ActiveSupport::Multibyte::Unicode.tidy_bytes(string).unicode_normalize(:nfc), + replacement: replacement + ) + end + + # Replaces special characters in a string so that it may be used as part of + # a 'pretty' URL. + # + # parameterize("Donald E. Knuth") # => "donald-e-knuth" + # parameterize("^très|Jolie-- ") # => "tres-jolie" + # + # To use a custom separator, override the +separator+ argument. + # + # parameterize("Donald E. Knuth", separator: '_') # => "donald_e_knuth" + # parameterize("^très|Jolie__ ", separator: '_') # => "tres_jolie" + # + # To preserve the case of the characters in a string, use the +preserve_case+ argument. + # + # parameterize("Donald E. Knuth", preserve_case: true) # => "Donald-E-Knuth" + # parameterize("^très|Jolie-- ", preserve_case: true) # => "tres-Jolie" + # + # It preserves dashes and underscores unless they are used as separators: + # + # parameterize("^très|Jolie__ ") # => "tres-jolie__" + # parameterize("^très|Jolie-- ", separator: "_") # => "tres_jolie--" + # parameterize("^très_Jolie-- ", separator: ".") # => "tres_jolie--" + # + def parameterize(string, separator: "-", preserve_case: false) + # Replace accented chars with their ASCII equivalents. + parameterized_string = transliterate(string) + + # Turn unwanted chars into the separator. + parameterized_string.gsub!(/[^a-z0-9\-_]+/i, separator) + + unless separator.nil? || separator.empty? + if separator == "-" + re_duplicate_separator = /-{2,}/ + re_leading_trailing_separator = /^-|-$/i + else + re_sep = Regexp.escape(separator) + re_duplicate_separator = /#{re_sep}{2,}/ + re_leading_trailing_separator = /^#{re_sep}|#{re_sep}$/i + end + # No more than one of the separator in a row. + parameterized_string.gsub!(re_duplicate_separator, separator) + # Remove leading/trailing separator. + parameterized_string.gsub!(re_leading_trailing_separator, "") + end + + parameterized_string.downcase! unless preserve_case + parameterized_string + end + end +end diff --git a/activesupport/lib/active_support/json.rb b/activesupport/lib/active_support/json.rb new file mode 100644 index 0000000000..d7887175c0 --- /dev/null +++ b/activesupport/lib/active_support/json.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require "active_support/json/decoding" +require "active_support/json/encoding" diff --git a/activesupport/lib/active_support/json/decoding.rb b/activesupport/lib/active_support/json/decoding.rb new file mode 100644 index 0000000000..402a3fbe60 --- /dev/null +++ b/activesupport/lib/active_support/json/decoding.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/module/delegation" +require "json" + +module ActiveSupport + # Look for and parse json strings that look like ISO 8601 times. + mattr_accessor :parse_json_times + + module JSON + # matches YAML-formatted dates + DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/ + DATETIME_REGEX = /^(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{1,2}-\d{1,2}[T \t]+\d{1,2}:\d{2}:\d{2}(\.[0-9]*)?(([ \t]*)Z|[-+]\d{2}?(:\d{2})?)?)$/ + + class << self + # Parses a JSON string (JavaScript Object Notation) into a hash. + # See http://www.json.org for more info. + # + # ActiveSupport::JSON.decode("{\"team\":\"rails\",\"players\":\"36\"}") + # => {"team" => "rails", "players" => "36"} + def decode(json) + data = ::JSON.parse(json, quirks_mode: true) + + if ActiveSupport.parse_json_times + convert_dates_from(data) + else + data + end + end + + # Returns the class of the error that will be raised when there is an + # error in decoding JSON. Using this method means you won't directly + # depend on the ActiveSupport's JSON implementation, in case it changes + # in the future. + # + # begin + # obj = ActiveSupport::JSON.decode(some_string) + # rescue ActiveSupport::JSON.parse_error + # Rails.logger.warn("Attempted to decode invalid JSON: #{some_string}") + # end + def parse_error + ::JSON::ParserError + end + + private + + def convert_dates_from(data) + case data + when nil + nil + when DATE_REGEX + begin + Date.parse(data) + rescue ArgumentError + data + end + when DATETIME_REGEX + begin + Time.zone.parse(data) + rescue ArgumentError + data + end + when Array + data.map! { |d| convert_dates_from(d) } + when Hash + data.each do |key, value| + data[key] = convert_dates_from(value) + end + else + data + end + end + end + end +end diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb new file mode 100644 index 0000000000..de1b8ac8cf --- /dev/null +++ b/activesupport/lib/active_support/json/encoding.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/json" +require "active_support/core_ext/module/delegation" + +module ActiveSupport + class << self + delegate :use_standard_json_time_format, :use_standard_json_time_format=, + :time_precision, :time_precision=, + :escape_html_entities_in_json, :escape_html_entities_in_json=, + :json_encoder, :json_encoder=, + to: :'ActiveSupport::JSON::Encoding' + end + + module JSON + # Dumps objects in JSON (JavaScript Object Notation). + # See http://www.json.org for more info. + # + # ActiveSupport::JSON.encode({ team: 'rails', players: '36' }) + # # => "{\"team\":\"rails\",\"players\":\"36\"}" + def self.encode(value, options = nil) + Encoding.json_encoder.new(options).encode(value) + end + + module Encoding #:nodoc: + class JSONGemEncoder #:nodoc: + attr_reader :options + + def initialize(options = nil) + @options = options || {} + end + + # Encode the given object into a JSON string + def encode(value) + stringify jsonify value.as_json(options.dup) + end + + private + # Rails does more escaping than the JSON gem natively does (we + # escape \u2028 and \u2029 and optionally >, <, & to work around + # certain browser problems). + ESCAPED_CHARS = { + "\u2028" => '\u2028', + "\u2029" => '\u2029', + ">" => '\u003e', + "<" => '\u003c', + "&" => '\u0026', + } + + ESCAPE_REGEX_WITH_HTML_ENTITIES = /[\u2028\u2029><&]/u + ESCAPE_REGEX_WITHOUT_HTML_ENTITIES = /[\u2028\u2029]/u + + # This class wraps all the strings we see and does the extra escaping + class EscapedString < String #:nodoc: + def to_json(*) + if Encoding.escape_html_entities_in_json + s = super + s.gsub! ESCAPE_REGEX_WITH_HTML_ENTITIES, ESCAPED_CHARS + s + else + s = super + s.gsub! ESCAPE_REGEX_WITHOUT_HTML_ENTITIES, ESCAPED_CHARS + s + end + end + + def to_s + self + end + end + + # Mark these as private so we don't leak encoding-specific constructs + private_constant :ESCAPED_CHARS, :ESCAPE_REGEX_WITH_HTML_ENTITIES, + :ESCAPE_REGEX_WITHOUT_HTML_ENTITIES, :EscapedString + + # Convert an object into a "JSON-ready" representation composed of + # primitives like Hash, Array, String, Numeric, + # and +true+/+false+/+nil+. + # Recursively calls #as_json to the object to recursively build a + # fully JSON-ready object. + # + # This allows developers to implement #as_json without having to + # worry about what base types of objects they are allowed to return + # or having to remember to call #as_json recursively. + # + # Note: the +options+ hash passed to +object.to_json+ is only passed + # to +object.as_json+, not any of this method's recursive +#as_json+ + # calls. + def jsonify(value) + case value + when String + EscapedString.new(value) + when Numeric, NilClass, TrueClass, FalseClass + value.as_json + when Hash + Hash[value.map { |k, v| [jsonify(k), jsonify(v)] }] + when Array + value.map { |v| jsonify(v) } + else + jsonify value.as_json + end + end + + # Encode a "jsonified" Ruby data structure using the JSON gem + def stringify(jsonified) + ::JSON.generate(jsonified, quirks_mode: true, max_nesting: false) + end + end + + class << self + # If true, use ISO 8601 format for dates and times. Otherwise, fall back + # to the Active Support legacy format. + attr_accessor :use_standard_json_time_format + + # If true, encode >, <, & as escaped unicode sequences (e.g. > as \u003e) + # as a safety measure. + attr_accessor :escape_html_entities_in_json + + # Sets the precision of encoded time values. + # Defaults to 3 (equivalent to millisecond precision) + attr_accessor :time_precision + + # Sets the encoder used by Rails to encode Ruby objects into JSON strings + # in +Object#to_json+ and +ActiveSupport::JSON.encode+. + attr_accessor :json_encoder + end + + self.use_standard_json_time_format = true + self.escape_html_entities_in_json = true + self.json_encoder = JSONGemEncoder + self.time_precision = 3 + end + end +end diff --git a/activesupport/lib/active_support/key_generator.rb b/activesupport/lib/active_support/key_generator.rb new file mode 100644 index 0000000000..00edcdd05a --- /dev/null +++ b/activesupport/lib/active_support/key_generator.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "concurrent/map" +require "openssl" + +module ActiveSupport + # KeyGenerator is a simple wrapper around OpenSSL's implementation of PBKDF2. + # It can be used to derive a number of keys for various purposes from a given secret. + # This lets Rails applications have a single secure secret, but avoid reusing that + # key in multiple incompatible contexts. + class KeyGenerator + def initialize(secret, options = {}) + @secret = secret + # The default iterations are higher than required for our key derivation uses + # on the off chance someone uses this for password storage + @iterations = options[:iterations] || 2**16 + end + + # Returns a derived key suitable for use. The default key_size is chosen + # to be compatible with the default settings of ActiveSupport::MessageVerifier. + # i.e. OpenSSL::Digest::SHA1#block_length + def generate_key(salt, key_size = 64) + OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size) + end + end + + # CachingKeyGenerator is a wrapper around KeyGenerator which allows users to avoid + # re-executing the key generation process when it's called using the same salt and + # key_size. + class CachingKeyGenerator + def initialize(key_generator) + @key_generator = key_generator + @cache_keys = Concurrent::Map.new + end + + # Returns a derived key suitable for use. + def generate_key(*args) + @cache_keys[args.join] ||= @key_generator.generate_key(*args) + end + end + + class LegacyKeyGenerator # :nodoc: + SECRET_MIN_LENGTH = 30 # Characters + + def initialize(secret) + ensure_secret_secure(secret) + @secret = secret + end + + def generate_key(salt) + @secret + end + + private + + # To prevent users from using something insecure like "Password" we make sure that the + # secret they've provided is at least 30 characters in length. + def ensure_secret_secure(secret) + if secret.blank? + raise ArgumentError, "A secret is required to generate an integrity hash " \ + "for cookie session data. Set a secret_key_base of at least " \ + "#{SECRET_MIN_LENGTH} characters by running `rails credentials:edit`." + end + + if secret.length < SECRET_MIN_LENGTH + raise ArgumentError, "Secret should be something secure, " \ + "like \"#{SecureRandom.hex(16)}\". The value you " \ + "provided, \"#{secret}\", is shorter than the minimum length " \ + "of #{SECRET_MIN_LENGTH} characters." + end + end + end +end diff --git a/activesupport/lib/active_support/lazy_load_hooks.rb b/activesupport/lib/active_support/lazy_load_hooks.rb new file mode 100644 index 0000000000..a6b096a973 --- /dev/null +++ b/activesupport/lib/active_support/lazy_load_hooks.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module ActiveSupport + # lazy_load_hooks allows Rails to lazily load a lot of components and thus + # making the app boot faster. Because of this feature now there is no need to + # require <tt>ActiveRecord::Base</tt> at boot time purely to apply + # configuration. Instead a hook is registered that applies configuration once + # <tt>ActiveRecord::Base</tt> is loaded. Here <tt>ActiveRecord::Base</tt> is + # used as example but this feature can be applied elsewhere too. + # + # Here is an example where +on_load+ method is called to register a hook. + # + # initializer 'active_record.initialize_timezone' do + # ActiveSupport.on_load(:active_record) do + # self.time_zone_aware_attributes = true + # self.default_timezone = :utc + # end + # end + # + # When the entirety of +ActiveRecord::Base+ has been + # evaluated then +run_load_hooks+ is invoked. The very last line of + # +ActiveRecord::Base+ is: + # + # ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base) + module LazyLoadHooks + def self.extended(base) # :nodoc: + base.class_eval do + @load_hooks = Hash.new { |h, k| h[k] = [] } + @loaded = Hash.new { |h, k| h[k] = [] } + @run_once = Hash.new { |h, k| h[k] = [] } + end + end + + # Declares a block that will be executed when a Rails component is fully + # loaded. + # + # Options: + # + # * <tt>:yield</tt> - Yields the object that run_load_hooks to +block+. + # * <tt>:run_once</tt> - Given +block+ will run only once. + def on_load(name, options = {}, &block) + @loaded[name].each do |base| + execute_hook(name, base, options, block) + end + + @load_hooks[name] << [block, options] + end + + def run_load_hooks(name, base = Object) + @loaded[name] << base + @load_hooks[name].each do |hook, options| + execute_hook(name, base, options, hook) + end + end + + private + + def with_execution_control(name, block, once) + unless @run_once[name].include?(block) + @run_once[name] << block if once + + yield + end + end + + def execute_hook(name, base, options, block) + with_execution_control(name, block, options[:run_once]) do + if options[:yield] + block.call(base) + else + if base.is_a?(Module) + base.class_eval(&block) + else + base.instance_eval(&block) + end + end + end + end + end + + extend LazyLoadHooks +end diff --git a/activesupport/lib/active_support/locale/en.rb b/activesupport/lib/active_support/locale/en.rb new file mode 100644 index 0000000000..a2a7ea7ae1 --- /dev/null +++ b/activesupport/lib/active_support/locale/en.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +{ + en: { + number: { + nth: { + ordinals: lambda do |_key, number:, **_options| + case number + when 1; "st" + when 2; "nd" + when 3; "rd" + when 4, 5, 6, 7, 8, 9, 10, 11, 12, 13; "th" + else + num_modulo = number.to_i.abs % 100 + num_modulo %= 10 if num_modulo > 13 + case num_modulo + when 1; "st" + when 2; "nd" + when 3; "rd" + else "th" + end + end + end, + + ordinalized: lambda do |_key, number:, **_options| + "#{number}#{ActiveSupport::Inflector.ordinal(number)}" + end + } + } + } +} diff --git a/activesupport/lib/active_support/locale/en.yml b/activesupport/lib/active_support/locale/en.yml new file mode 100644 index 0000000000..c64b7598ee --- /dev/null +++ b/activesupport/lib/active_support/locale/en.yml @@ -0,0 +1,135 @@ +en: + date: + formats: + # Use the strftime parameters for formats. + # When no format has been given, it uses default. + # You can provide other formats here if you like! + default: "%Y-%m-%d" + short: "%b %d" + long: "%B %d, %Y" + + day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday] + abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat] + + # Don't forget the nil at the beginning; there's no such thing as a 0th month + month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December] + abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec] + # Used in date_select and datetime_select. + order: + - year + - month + - day + + time: + formats: + default: "%a, %d %b %Y %H:%M:%S %z" + short: "%d %b %H:%M" + long: "%B %d, %Y %H:%M" + am: "am" + pm: "pm" + +# Used in array.to_sentence. + support: + array: + words_connector: ", " + two_words_connector: " and " + last_word_connector: ", and " + number: + # Used in NumberHelper.number_to_delimited() + # These are also the defaults for 'currency', 'percentage', 'precision', and 'human' + format: + # Sets the separator between the units, for more precision (e.g. 1.0 / 2.0 == 0.5) + separator: "." + # Delimits thousands (e.g. 1,000,000 is a million) (always in groups of three) + delimiter: "," + # Number of decimals, behind the separator (the number 1 with a precision of 2 gives: 1.00) + precision: 3 + # If set to true, precision will mean the number of significant digits instead + # of the number of decimal digits (1234 with precision 2 becomes 1200, 1.23543 becomes 1.2) + significant: false + # If set, the zeros after the decimal separator will always be stripped (eg.: 1.200 will be 1.2) + strip_insignificant_zeros: false + + # Used in NumberHelper.number_to_currency() + currency: + format: + # Where is the currency sign? %u is the currency unit, %n the number (default: $5.00) + format: "%u%n" + unit: "$" + # These five are to override number.format and are optional + separator: "." + delimiter: "," + precision: 2 + significant: false + strip_insignificant_zeros: false + + # Used in NumberHelper.number_to_percentage() + percentage: + format: + # These five are to override number.format and are optional + # separator: + delimiter: "" + # precision: + # significant: false + # strip_insignificant_zeros: false + format: "%n%" + + # Used in NumberHelper.number_to_rounded() + precision: + format: + # These five are to override number.format and are optional + # separator: + delimiter: "" + # precision: + # significant: false + # strip_insignificant_zeros: false + + # Used in NumberHelper.number_to_human_size() and NumberHelper.number_to_human() + human: + format: + # These five are to override number.format and are optional + # separator: + delimiter: "" + precision: 3 + significant: true + strip_insignificant_zeros: true + # Used in number_to_human_size() + storage_units: + # Storage units output formatting. + # %u is the storage unit, %n is the number (default: 2 MB) + format: "%n %u" + units: + byte: + one: "Byte" + other: "Bytes" + kb: "KB" + mb: "MB" + gb: "GB" + tb: "TB" + pb: "PB" + eb: "EB" + # Used in NumberHelper.number_to_human() + decimal_units: + format: "%n %u" + # Decimal units output formatting + # By default we will only quantify some of the exponents + # but the commented ones might be defined or overridden + # by the user. + units: + # femto: Quadrillionth + # pico: Trillionth + # nano: Billionth + # micro: Millionth + # mili: Thousandth + # centi: Hundredth + # deci: Tenth + unit: "" + # ten: + # one: Ten + # other: Tens + # hundred: Hundred + thousand: Thousand + million: Million + billion: Billion + trillion: Trillion + quadrillion: Quadrillion diff --git a/activesupport/lib/active_support/log_subscriber.rb b/activesupport/lib/active_support/log_subscriber.rb new file mode 100644 index 0000000000..0f7be06c8e --- /dev/null +++ b/activesupport/lib/active_support/log_subscriber.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" +require "active_support/core_ext/class/attribute" +require "active_support/subscriber" + +module ActiveSupport + # ActiveSupport::LogSubscriber is an object set to consume + # ActiveSupport::Notifications with the sole purpose of logging them. + # The log subscriber dispatches notifications to a registered object based + # on its given namespace. + # + # An example would be Active Record log subscriber responsible for logging + # queries: + # + # module ActiveRecord + # class LogSubscriber < ActiveSupport::LogSubscriber + # def sql(event) + # "#{event.payload[:name]} (#{event.duration}) #{event.payload[:sql]}" + # end + # end + # end + # + # And it's finally registered as: + # + # ActiveRecord::LogSubscriber.attach_to :active_record + # + # Since we need to know all instance methods before attaching the log + # subscriber, the line above should be called after your + # <tt>ActiveRecord::LogSubscriber</tt> definition. + # + # After configured, whenever a "sql.active_record" notification is published, + # it will properly dispatch the event (ActiveSupport::Notifications::Event) to + # the sql method. + # + # Log subscriber also has some helpers to deal with logging and automatically + # flushes all logs when the request finishes (via action_dispatch.callback + # notification) in a Rails environment. + class LogSubscriber < Subscriber + # Embed in a String to clear all previous ANSI sequences. + CLEAR = "\e[0m" + BOLD = "\e[1m" + + # Colors + BLACK = "\e[30m" + RED = "\e[31m" + GREEN = "\e[32m" + YELLOW = "\e[33m" + BLUE = "\e[34m" + MAGENTA = "\e[35m" + CYAN = "\e[36m" + WHITE = "\e[37m" + + mattr_accessor :colorize_logging, default: true + + class << self + def logger + @logger ||= if defined?(Rails) && Rails.respond_to?(:logger) + Rails.logger + end + end + + attr_writer :logger + + def log_subscribers + subscribers + end + + # Flush all log_subscribers' logger. + def flush_all! + logger.flush if logger.respond_to?(:flush) + end + end + + def logger + LogSubscriber.logger + end + + def start(name, id, payload) + super if logger + end + + def finish(name, id, payload) + super if logger + rescue => e + if logger + logger.error "Could not log #{name.inspect} event. #{e.class}: #{e.message} #{e.backtrace}" + end + end + + private + + %w(info debug warn error fatal unknown).each do |level| + class_eval <<-METHOD, __FILE__, __LINE__ + 1 + def #{level}(progname = nil, &block) + logger.#{level}(progname, &block) if logger + end + METHOD + end + + # Set color by using a symbol or one of the defined constants. If a third + # option is set to +true+, it also adds bold to the string. This is based + # on the Highline implementation and will automatically append CLEAR to the + # end of the returned String. + def color(text, color, bold = false) # :doc: + return text unless colorize_logging + color = self.class.const_get(color.upcase) if color.is_a?(Symbol) + bold = bold ? BOLD : "" + "#{bold}#{color}#{text}#{CLEAR}" + end + end +end diff --git a/activesupport/lib/active_support/log_subscriber/test_helper.rb b/activesupport/lib/active_support/log_subscriber/test_helper.rb new file mode 100644 index 0000000000..3f19ef5009 --- /dev/null +++ b/activesupport/lib/active_support/log_subscriber/test_helper.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "active_support/log_subscriber" +require "active_support/logger" +require "active_support/notifications" + +module ActiveSupport + class LogSubscriber + # Provides some helpers to deal with testing log subscribers by setting up + # notifications. Take for instance Active Record subscriber tests: + # + # class SyncLogSubscriberTest < ActiveSupport::TestCase + # include ActiveSupport::LogSubscriber::TestHelper + # + # setup do + # ActiveRecord::LogSubscriber.attach_to(:active_record) + # end + # + # def test_basic_query_logging + # Developer.all.to_a + # wait + # assert_equal 1, @logger.logged(:debug).size + # assert_match(/Developer Load/, @logger.logged(:debug).last) + # assert_match(/SELECT \* FROM "developers"/, @logger.logged(:debug).last) + # end + # end + # + # All you need to do is to ensure that your log subscriber is added to + # Rails::Subscriber, as in the second line of the code above. The test + # helpers are responsible for setting up the queue, subscriptions and + # turning colors in logs off. + # + # The messages are available in the @logger instance, which is a logger with + # limited powers (it actually does not send anything to your output), and + # you can collect them doing @logger.logged(level), where level is the level + # used in logging, like info, debug, warn and so on. + module TestHelper + def setup # :nodoc: + @logger = MockLogger.new + @notifier = ActiveSupport::Notifications::Fanout.new + + ActiveSupport::LogSubscriber.colorize_logging = false + + @old_notifier = ActiveSupport::Notifications.notifier + set_logger(@logger) + ActiveSupport::Notifications.notifier = @notifier + end + + def teardown # :nodoc: + set_logger(nil) + ActiveSupport::Notifications.notifier = @old_notifier + end + + class MockLogger + include ActiveSupport::Logger::Severity + + attr_reader :flush_count + attr_accessor :level + + def initialize(level = DEBUG) + @flush_count = 0 + @level = level + @logged = Hash.new { |h, k| h[k] = [] } + end + + def method_missing(level, message = nil) + if block_given? + @logged[level] << yield + else + @logged[level] << message + end + end + + def logged(level) + @logged[level].compact.map { |l| l.to_s.strip } + end + + def flush + @flush_count += 1 + end + + ActiveSupport::Logger::Severity.constants.each do |severity| + class_eval <<-EOT, __FILE__, __LINE__ + 1 + def #{severity.downcase}? + #{severity} >= @level + end + EOT + end + end + + # Wait notifications to be published. + def wait + @notifier.wait + end + + # Overwrite if you use another logger in your log subscriber. + # + # def logger + # ActiveRecord::Base.logger = @logger + # end + def set_logger(logger) + ActiveSupport::LogSubscriber.logger = logger + end + end + end +end diff --git a/activesupport/lib/active_support/logger.rb b/activesupport/lib/active_support/logger.rb new file mode 100644 index 0000000000..b8555c887b --- /dev/null +++ b/activesupport/lib/active_support/logger.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "active_support/logger_silence" +require "active_support/logger_thread_safe_level" +require "logger" + +module ActiveSupport + class Logger < ::Logger + include LoggerSilence + + # Returns true if the logger destination matches one of the sources + # + # logger = Logger.new(STDOUT) + # ActiveSupport::Logger.logger_outputs_to?(logger, STDOUT) + # # => true + def self.logger_outputs_to?(logger, *sources) + logdev = logger.instance_variable_get("@logdev") + logger_source = logdev.dev if logdev.respond_to?(:dev) + sources.any? { |source| source == logger_source } + end + + # Broadcasts logs to multiple loggers. + def self.broadcast(logger) # :nodoc: + Module.new do + define_method(:add) do |*args, &block| + logger.add(*args, &block) + super(*args, &block) + end + + define_method(:<<) do |x| + logger << x + super(x) + end + + define_method(:close) do + logger.close + super() + end + + define_method(:progname=) do |name| + logger.progname = name + super(name) + end + + define_method(:formatter=) do |formatter| + logger.formatter = formatter + super(formatter) + end + + define_method(:level=) do |level| + logger.level = level + super(level) + end + + define_method(:local_level=) do |level| + logger.local_level = level if logger.respond_to?(:local_level=) + super(level) if respond_to?(:local_level=) + end + + define_method(:silence) do |level = Logger::ERROR, &block| + if logger.respond_to?(:silence) + logger.silence(level) do + if defined?(super) + super(level, &block) + else + block.call(self) + end + end + else + if defined?(super) + super(level, &block) + else + block.call(self) + end + end + end + end + end + + def initialize(*args) + super + @formatter = SimpleFormatter.new + end + + # Simple formatter which only displays the message. + class SimpleFormatter < ::Logger::Formatter + # This method is invoked when a log event occurs + def call(severity, timestamp, progname, msg) + "#{String === msg ? msg : msg.inspect}\n" + end + end + end +end diff --git a/activesupport/lib/active_support/logger_silence.rb b/activesupport/lib/active_support/logger_silence.rb new file mode 100644 index 0000000000..b2444c1e34 --- /dev/null +++ b/activesupport/lib/active_support/logger_silence.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "active_support/core_ext/module/attribute_accessors" +require "active_support/logger_thread_safe_level" + +module LoggerSilence + extend ActiveSupport::Concern + + included do + ActiveSupport::Deprecation.warn( + "Including LoggerSilence is deprecated and will be removed in Rails 6.1. " \ + "Please use `ActiveSupport::LoggerSilence` instead" + ) + + include ActiveSupport::LoggerSilence + end +end + +module ActiveSupport + module LoggerSilence + extend ActiveSupport::Concern + + included do + cattr_accessor :silencer, default: true + include ActiveSupport::LoggerThreadSafeLevel + end + + # Silences the logger for the duration of the block. + def silence(temporary_level = Logger::ERROR) + if silencer + begin + old_local_level = local_level + self.local_level = temporary_level + + yield self + ensure + self.local_level = old_local_level + end + else + yield self + end + end + end +end diff --git a/activesupport/lib/active_support/logger_thread_safe_level.rb b/activesupport/lib/active_support/logger_thread_safe_level.rb new file mode 100644 index 0000000000..f16c90cfc6 --- /dev/null +++ b/activesupport/lib/active_support/logger_thread_safe_level.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "active_support/core_ext/module/attribute_accessors" +require "concurrent" + +module ActiveSupport + module LoggerThreadSafeLevel # :nodoc: + extend ActiveSupport::Concern + + included do + cattr_accessor :local_levels, default: Concurrent::Map.new(initial_capacity: 2), instance_accessor: false + end + + Logger::Severity.constants.each do |severity| + class_eval(<<-EOT, __FILE__, __LINE__ + 1) + def #{severity.downcase}? # def debug? + Logger::#{severity} >= level # DEBUG >= level + end # end + EOT + end + + def after_initialize + ActiveSupport::Deprecation.warn( + "Logger don't need to call #after_initialize directly anymore. It will be deprecated without replacement in " \ + "Rails 6.1." + ) + end + + def local_log_id + Thread.current.__id__ + end + + def local_level + self.class.local_levels[local_log_id] + end + + def local_level=(level) + if level + self.class.local_levels[local_log_id] = level + else + self.class.local_levels.delete(local_log_id) + end + end + + def level + local_level || super + end + + def add(severity, message = nil, progname = nil, &block) # :nodoc: + return true if @logdev.nil? || (severity || UNKNOWN) < level + super + end + end +end diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb new file mode 100644 index 0000000000..6f7302e732 --- /dev/null +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "openssl" +require "base64" +require "active_support/core_ext/array/extract_options" +require "active_support/core_ext/module/attribute_accessors" +require "active_support/message_verifier" +require "active_support/messages/metadata" + +module ActiveSupport + # MessageEncryptor is a simple way to encrypt values which get stored + # somewhere you don't trust. + # + # The cipher text and initialization vector are base64 encoded and returned + # to you. + # + # This can be used in situations similar to the <tt>MessageVerifier</tt>, but + # where you don't want users to be able to determine the value of the payload. + # + # len = ActiveSupport::MessageEncryptor.key_len + # salt = SecureRandom.random_bytes(len) + # key = ActiveSupport::KeyGenerator.new('password').generate_key(salt, len) # => "\x89\xE0\x156\xAC..." + # crypt = ActiveSupport::MessageEncryptor.new(key) # => #<ActiveSupport::MessageEncryptor ...> + # encrypted_data = crypt.encrypt_and_sign('my secret data') # => "NlFBTTMwOUV5UlA1QlNEN2xkY2d6eThYWWh..." + # crypt.decrypt_and_verify(encrypted_data) # => "my secret data" + # + # === Confining messages to a specific purpose + # + # By default any message can be used throughout your app. But they can also be + # confined to a specific +:purpose+. + # + # token = crypt.encrypt_and_sign("this is the chair", purpose: :login) + # + # Then that same purpose must be passed when verifying to get the data back out: + # + # crypt.decrypt_and_verify(token, purpose: :login) # => "this is the chair" + # crypt.decrypt_and_verify(token, purpose: :shipping) # => nil + # crypt.decrypt_and_verify(token) # => nil + # + # Likewise, if a message has no purpose it won't be returned when verifying with + # a specific purpose. + # + # token = crypt.encrypt_and_sign("the conversation is lively") + # crypt.decrypt_and_verify(token, purpose: :scare_tactics) # => nil + # crypt.decrypt_and_verify(token) # => "the conversation is lively" + # + # === Making messages expire + # + # By default messages last forever and verifying one year from now will still + # return the original value. But messages can be set to expire at a given + # time with +:expires_in+ or +:expires_at+. + # + # crypt.encrypt_and_sign(parcel, expires_in: 1.month) + # crypt.encrypt_and_sign(doowad, expires_at: Time.now.end_of_year) + # + # Then the messages can be verified and returned upto the expire time. + # Thereafter, verifying returns +nil+. + # + # === Rotating keys + # + # MessageEncryptor also supports rotating out old configurations by falling + # back to a stack of encryptors. Call +rotate+ to build and add an encryptor + # so +decrypt_and_verify+ will also try the fallback. + # + # By default any rotated encryptors use the values of the primary + # encryptor unless specified otherwise. + # + # You'd give your encryptor the new defaults: + # + # crypt = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + # + # Then gradually rotate the old values out by adding them as fallbacks. Any message + # generated with the old values will then work until the rotation is removed. + # + # crypt.rotate old_secret # Fallback to an old secret instead of @secret. + # crypt.rotate cipher: "aes-256-cbc" # Fallback to an old cipher instead of aes-256-gcm. + # + # Though if both the secret and the cipher was changed at the same time, + # the above should be combined into: + # + # crypt.rotate old_secret, cipher: "aes-256-cbc" + class MessageEncryptor + prepend Messages::Rotator::Encryptor + + cattr_accessor :use_authenticated_message_encryption, instance_accessor: false, default: false + + class << self + def default_cipher #:nodoc: + if use_authenticated_message_encryption + "aes-256-gcm" + else + "aes-256-cbc" + end + end + end + + module NullSerializer #:nodoc: + def self.load(value) + value + end + + def self.dump(value) + value + end + end + + module NullVerifier #:nodoc: + def self.verify(value) + value + end + + def self.generate(value) + value + end + end + + class InvalidMessage < StandardError; end + OpenSSLCipherError = OpenSSL::Cipher::CipherError + + # Initialize a new MessageEncryptor. +secret+ must be at least as long as + # the cipher key size. For the default 'aes-256-gcm' cipher, this is 256 + # bits. If you are using a user-entered secret, you can generate a suitable + # key by using <tt>ActiveSupport::KeyGenerator</tt> or a similar key + # derivation function. + # + # First additional parameter is used as the signature key for +MessageVerifier+. + # This allows you to specify keys to encrypt and sign data. + # + # ActiveSupport::MessageEncryptor.new('secret', 'signature_secret') + # + # Options: + # * <tt>:cipher</tt> - Cipher to use. Can be any cipher returned by + # <tt>OpenSSL::Cipher.ciphers</tt>. Default is 'aes-256-gcm'. + # * <tt>:digest</tt> - String of digest to use for signing. Default is + # +SHA1+. Ignored when using an AEAD cipher like 'aes-256-gcm'. + # * <tt>:serializer</tt> - Object serializer to use. Default is +Marshal+. + def initialize(secret, *signature_key_or_options) + options = signature_key_or_options.extract_options! + sign_secret = signature_key_or_options.first + @secret = secret + @sign_secret = sign_secret + @cipher = options[:cipher] || self.class.default_cipher + @digest = options[:digest] || "SHA1" unless aead_mode? + @verifier = resolve_verifier + @serializer = options[:serializer] || Marshal + end + + # Encrypt and sign a message. We need to sign the message in order to avoid + # padding attacks. Reference: https://www.limited-entropy.com/padding-oracle-attacks/. + def encrypt_and_sign(value, expires_at: nil, expires_in: nil, purpose: nil) + verifier.generate(_encrypt(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose)) + end + + # Decrypt and verify a message. We need to verify the message in order to + # avoid padding attacks. Reference: https://www.limited-entropy.com/padding-oracle-attacks/. + def decrypt_and_verify(data, purpose: nil, **) + _decrypt(verifier.verify(data), purpose) + end + + # Given a cipher, returns the key length of the cipher to help generate the key of desired size + def self.key_len(cipher = default_cipher) + OpenSSL::Cipher.new(cipher).key_len + end + + private + def _encrypt(value, **metadata_options) + cipher = new_cipher + cipher.encrypt + cipher.key = @secret + + # Rely on OpenSSL for the initialization vector + iv = cipher.random_iv + cipher.auth_data = "" if aead_mode? + + encrypted_data = cipher.update(Messages::Metadata.wrap(@serializer.dump(value), metadata_options)) + encrypted_data << cipher.final + + blob = "#{::Base64.strict_encode64 encrypted_data}--#{::Base64.strict_encode64 iv}" + blob = "#{blob}--#{::Base64.strict_encode64 cipher.auth_tag}" if aead_mode? + blob + end + + def _decrypt(encrypted_message, purpose) + cipher = new_cipher + encrypted_data, iv, auth_tag = encrypted_message.split("--").map { |v| ::Base64.strict_decode64(v) } + + # Currently the OpenSSL bindings do not raise an error if auth_tag is + # truncated, which would allow an attacker to easily forge it. See + # https://github.com/ruby/openssl/issues/63 + raise InvalidMessage if aead_mode? && (auth_tag.nil? || auth_tag.bytes.length != 16) + + cipher.decrypt + cipher.key = @secret + cipher.iv = iv + if aead_mode? + cipher.auth_tag = auth_tag + cipher.auth_data = "" + end + + decrypted_data = cipher.update(encrypted_data) + decrypted_data << cipher.final + + message = Messages::Metadata.verify(decrypted_data, purpose) + @serializer.load(message) if message + rescue OpenSSLCipherError, TypeError, ArgumentError + raise InvalidMessage + end + + def new_cipher + OpenSSL::Cipher.new(@cipher) + end + + attr_reader :verifier + + def aead_mode? + @aead_mode ||= new_cipher.authenticated? + end + + def resolve_verifier + if aead_mode? + NullVerifier + else + MessageVerifier.new(@sign_secret || @secret, digest: @digest, serializer: NullSerializer) + end + end + end +end diff --git a/activesupport/lib/active_support/message_verifier.rb b/activesupport/lib/active_support/message_verifier.rb new file mode 100644 index 0000000000..64c557bec6 --- /dev/null +++ b/activesupport/lib/active_support/message_verifier.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "base64" +require "active_support/core_ext/object/blank" +require "active_support/security_utils" +require "active_support/messages/metadata" +require "active_support/messages/rotator" + +module ActiveSupport + # +MessageVerifier+ makes it easy to generate and verify messages which are + # signed to prevent tampering. + # + # This is useful for cases like remember-me tokens and auto-unsubscribe links + # where the session store isn't suitable or available. + # + # Remember Me: + # cookies[:remember_me] = @verifier.generate([@user.id, 2.weeks.from_now]) + # + # In the authentication filter: + # + # id, time = @verifier.verify(cookies[:remember_me]) + # if Time.now < time + # self.current_user = User.find(id) + # end + # + # By default it uses Marshal to serialize the message. If you want to use + # another serialization method, you can set the serializer in the options + # hash upon initialization: + # + # @verifier = ActiveSupport::MessageVerifier.new('s3Krit', serializer: YAML) + # + # +MessageVerifier+ creates HMAC signatures using SHA1 hash algorithm by default. + # If you want to use a different hash algorithm, you can change it by providing + # +:digest+ key as an option while initializing the verifier: + # + # @verifier = ActiveSupport::MessageVerifier.new('s3Krit', digest: 'SHA256') + # + # === Confining messages to a specific purpose + # + # By default any message can be used throughout your app. But they can also be + # confined to a specific +:purpose+. + # + # token = @verifier.generate("this is the chair", purpose: :login) + # + # Then that same purpose must be passed when verifying to get the data back out: + # + # @verifier.verified(token, purpose: :login) # => "this is the chair" + # @verifier.verified(token, purpose: :shipping) # => nil + # @verifier.verified(token) # => nil + # + # @verifier.verify(token, purpose: :login) # => "this is the chair" + # @verifier.verify(token, purpose: :shipping) # => ActiveSupport::MessageVerifier::InvalidSignature + # @verifier.verify(token) # => ActiveSupport::MessageVerifier::InvalidSignature + # + # Likewise, if a message has no purpose it won't be returned when verifying with + # a specific purpose. + # + # token = @verifier.generate("the conversation is lively") + # @verifier.verified(token, purpose: :scare_tactics) # => nil + # @verifier.verified(token) # => "the conversation is lively" + # + # @verifier.verify(token, purpose: :scare_tactics) # => ActiveSupport::MessageVerifier::InvalidSignature + # @verifier.verify(token) # => "the conversation is lively" + # + # === Making messages expire + # + # By default messages last forever and verifying one year from now will still + # return the original value. But messages can be set to expire at a given + # time with +:expires_in+ or +:expires_at+. + # + # @verifier.generate(parcel, expires_in: 1.month) + # @verifier.generate(doowad, expires_at: Time.now.end_of_year) + # + # Then the messages can be verified and returned upto the expire time. + # Thereafter, the +verified+ method returns +nil+ while +verify+ raises + # <tt>ActiveSupport::MessageVerifier::InvalidSignature</tt>. + # + # === Rotating keys + # + # MessageVerifier also supports rotating out old configurations by falling + # back to a stack of verifiers. Call +rotate+ to build and add a verifier to + # so either +verified+ or +verify+ will also try verifying with the fallback. + # + # By default any rotated verifiers use the values of the primary + # verifier unless specified otherwise. + # + # You'd give your verifier the new defaults: + # + # verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA512", serializer: JSON) + # + # Then gradually rotate the old values out by adding them as fallbacks. Any message + # generated with the old values will then work until the rotation is removed. + # + # verifier.rotate old_secret # Fallback to an old secret instead of @secret. + # verifier.rotate digest: "SHA256" # Fallback to an old digest instead of SHA512. + # verifier.rotate serializer: Marshal # Fallback to an old serializer instead of JSON. + # + # Though the above would most likely be combined into one rotation: + # + # verifier.rotate old_secret, digest: "SHA256", serializer: Marshal + class MessageVerifier + prepend Messages::Rotator::Verifier + + class InvalidSignature < StandardError; end + + def initialize(secret, options = {}) + raise ArgumentError, "Secret should not be nil." unless secret + @secret = secret + @digest = options[:digest] || "SHA1" + @serializer = options[:serializer] || Marshal + end + + # Checks if a signed message could have been generated by signing an object + # with the +MessageVerifier+'s secret. + # + # verifier = ActiveSupport::MessageVerifier.new 's3Krit' + # signed_message = verifier.generate 'a private message' + # verifier.valid_message?(signed_message) # => true + # + # tampered_message = signed_message.chop # editing the message invalidates the signature + # verifier.valid_message?(tampered_message) # => false + def valid_message?(signed_message) + return if signed_message.nil? || !signed_message.valid_encoding? || signed_message.blank? + + data, digest = signed_message.split("--") + data.present? && digest.present? && ActiveSupport::SecurityUtils.secure_compare(digest, generate_digest(data)) + end + + # Decodes the signed message using the +MessageVerifier+'s secret. + # + # verifier = ActiveSupport::MessageVerifier.new 's3Krit' + # + # signed_message = verifier.generate 'a private message' + # verifier.verified(signed_message) # => 'a private message' + # + # Returns +nil+ if the message was not signed with the same secret. + # + # other_verifier = ActiveSupport::MessageVerifier.new 'd1ff3r3nt-s3Krit' + # other_verifier.verified(signed_message) # => nil + # + # Returns +nil+ if the message is not Base64-encoded. + # + # invalid_message = "f--46a0120593880c733a53b6dad75b42ddc1c8996d" + # verifier.verified(invalid_message) # => nil + # + # Raises any error raised while decoding the signed message. + # + # incompatible_message = "test--dad7b06c94abba8d46a15fafaef56c327665d5ff" + # verifier.verified(incompatible_message) # => TypeError: incompatible marshal file format + def verified(signed_message, purpose: nil, **) + if valid_message?(signed_message) + begin + data = signed_message.split("--")[0] + message = Messages::Metadata.verify(decode(data), purpose) + @serializer.load(message) if message + rescue ArgumentError => argument_error + return if argument_error.message.include?("invalid base64") + raise + end + end + end + + # Decodes the signed message using the +MessageVerifier+'s secret. + # + # verifier = ActiveSupport::MessageVerifier.new 's3Krit' + # signed_message = verifier.generate 'a private message' + # + # verifier.verify(signed_message) # => 'a private message' + # + # Raises +InvalidSignature+ if the message was not signed with the same + # secret or was not Base64-encoded. + # + # other_verifier = ActiveSupport::MessageVerifier.new 'd1ff3r3nt-s3Krit' + # other_verifier.verify(signed_message) # => ActiveSupport::MessageVerifier::InvalidSignature + def verify(*args) + verified(*args) || raise(InvalidSignature) + end + + # Generates a signed message for the provided value. + # + # The message is signed with the +MessageVerifier+'s secret. Without knowing + # the secret, the original value cannot be extracted from the message. + # + # verifier = ActiveSupport::MessageVerifier.new 's3Krit' + # verifier.generate 'a private message' # => "BAhJIhRwcml2YXRlLW1lc3NhZ2UGOgZFVA==--e2d724331ebdee96a10fb99b089508d1c72bd772" + def generate(value, expires_at: nil, expires_in: nil, purpose: nil) + data = encode(Messages::Metadata.wrap(@serializer.dump(value), expires_at: expires_at, expires_in: expires_in, purpose: purpose)) + "#{data}--#{generate_digest(data)}" + end + + private + def encode(data) + ::Base64.strict_encode64(data) + end + + def decode(data) + ::Base64.strict_decode64(data) + end + + def generate_digest(data) + require "openssl" unless defined?(OpenSSL) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data) + end + end +end diff --git a/activesupport/lib/active_support/messages/metadata.rb b/activesupport/lib/active_support/messages/metadata.rb new file mode 100644 index 0000000000..e97caac766 --- /dev/null +++ b/activesupport/lib/active_support/messages/metadata.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "time" + +module ActiveSupport + module Messages #:nodoc: + class Metadata #:nodoc: + def initialize(message, expires_at = nil, purpose = nil) + @message, @expires_at, @purpose = message, expires_at, purpose + end + + def as_json(options = {}) + { _rails: { message: @message, exp: @expires_at, pur: @purpose } } + end + + class << self + def wrap(message, expires_at: nil, expires_in: nil, purpose: nil) + if expires_at || expires_in || purpose + JSON.encode new(encode(message), pick_expiry(expires_at, expires_in), purpose) + else + message + end + end + + def verify(message, purpose) + extract_metadata(message).verify(purpose) + end + + private + def pick_expiry(expires_at, expires_in) + if expires_at + expires_at.utc.iso8601(3) + elsif expires_in + Time.now.utc.advance(seconds: expires_in).iso8601(3) + end + end + + def extract_metadata(message) + data = JSON.decode(message) rescue nil + + if data.is_a?(Hash) && data.key?("_rails") + new(decode(data["_rails"]["message"]), data["_rails"]["exp"], data["_rails"]["pur"]) + else + new(message) + end + end + + def encode(message) + ::Base64.strict_encode64(message) + end + + def decode(message) + ::Base64.strict_decode64(message) + end + end + + def verify(purpose) + @message if match?(purpose) && fresh? + end + + private + def match?(purpose) + @purpose.to_s == purpose.to_s + end + + def fresh? + @expires_at.nil? || Time.now.utc < Time.iso8601(@expires_at) + end + end + end +end diff --git a/activesupport/lib/active_support/messages/rotation_configuration.rb b/activesupport/lib/active_support/messages/rotation_configuration.rb new file mode 100644 index 0000000000..bd50d6d348 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotation_configuration.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + class RotationConfiguration # :nodoc: + attr_reader :signed, :encrypted + + def initialize + @signed, @encrypted = [], [] + end + + def rotate(kind, *args) + case kind + when :signed + @signed << args + when :encrypted + @encrypted << args + end + end + end + end +end diff --git a/activesupport/lib/active_support/messages/rotator.rb b/activesupport/lib/active_support/messages/rotator.rb new file mode 100644 index 0000000000..823a399d67 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotator.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + module Rotator # :nodoc: + def initialize(*, **options) + super + + @options = options + @rotations = [] + end + + def rotate(*secrets, **options) + @rotations << build_rotation(*secrets, @options.merge(options)) + end + + module Encryptor + include Rotator + + def decrypt_and_verify(*args, on_rotation: nil, **options) + super + rescue MessageEncryptor::InvalidMessage, MessageVerifier::InvalidSignature + run_rotations(on_rotation) { |encryptor| encryptor.decrypt_and_verify(*args, options) } || raise + end + + private + def build_rotation(secret = @secret, sign_secret = @sign_secret, options) + self.class.new(secret, sign_secret, options) + end + end + + module Verifier + include Rotator + + def verified(*args, on_rotation: nil, **options) + super || run_rotations(on_rotation) { |verifier| verifier.verified(*args, options) } + end + + private + def build_rotation(secret = @secret, options) + self.class.new(secret, options) + end + end + + private + def run_rotations(on_rotation) + @rotations.find do |rotation| + if message = yield(rotation) rescue next + on_rotation.call if on_rotation + return message + end + end + end + end + end +end diff --git a/activesupport/lib/active_support/multibyte.rb b/activesupport/lib/active_support/multibyte.rb new file mode 100644 index 0000000000..3fe3a05e93 --- /dev/null +++ b/activesupport/lib/active_support/multibyte.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ActiveSupport #:nodoc: + module Multibyte + autoload :Chars, "active_support/multibyte/chars" + autoload :Unicode, "active_support/multibyte/unicode" + + # The proxy class returned when calling mb_chars. You can use this accessor + # to configure your own proxy class so you can support other encodings. See + # the ActiveSupport::Multibyte::Chars implementation for an example how to + # do this. + # + # ActiveSupport::Multibyte.proxy_class = CharsForUTF32 + def self.proxy_class=(klass) + @proxy_class = klass + end + + # Returns the current proxy class. + def self.proxy_class + @proxy_class ||= ActiveSupport::Multibyte::Chars + end + end +end diff --git a/activesupport/lib/active_support/multibyte/chars.rb b/activesupport/lib/active_support/multibyte/chars.rb new file mode 100644 index 0000000000..a1e23aeaca --- /dev/null +++ b/activesupport/lib/active_support/multibyte/chars.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "active_support/json" +require "active_support/core_ext/string/access" +require "active_support/core_ext/string/behavior" +require "active_support/core_ext/module/delegation" + +module ActiveSupport #:nodoc: + module Multibyte #:nodoc: + # Chars enables you to work transparently with UTF-8 encoding in the Ruby + # String class without having extensive knowledge about the encoding. A + # Chars object accepts a string upon initialization and proxies String + # methods in an encoding safe manner. All the normal String methods are also + # implemented on the proxy. + # + # String methods are proxied through the Chars object, and can be accessed + # through the +mb_chars+ method. Methods which would normally return a + # String object now return a Chars object so methods can be chained. + # + # 'The Perfect String '.mb_chars.downcase.strip + # # => #<ActiveSupport::Multibyte::Chars:0x007fdc434ccc10 @wrapped_string="the perfect string"> + # + # Chars objects are perfectly interchangeable with String objects as long as + # no explicit class checks are made. If certain methods do explicitly check + # the class, call +to_s+ before you pass chars objects to them. + # + # bad.explicit_checking_method 'T'.mb_chars.downcase.to_s + # + # The default Chars implementation assumes that the encoding of the string + # is UTF-8, if you want to handle different encodings you can write your own + # multibyte string handler and configure it through + # ActiveSupport::Multibyte.proxy_class. + # + # class CharsForUTF32 + # def size + # @wrapped_string.size / 4 + # end + # + # def self.accepts?(string) + # string.length % 4 == 0 + # end + # end + # + # ActiveSupport::Multibyte.proxy_class = CharsForUTF32 + class Chars + include Comparable + attr_reader :wrapped_string + alias to_s wrapped_string + alias to_str wrapped_string + + delegate :<=>, :=~, :acts_like_string?, to: :wrapped_string + + # Creates a new Chars instance by wrapping _string_. + def initialize(string) + @wrapped_string = string + @wrapped_string.force_encoding(Encoding::UTF_8) unless @wrapped_string.frozen? + end + + # Forward all undefined methods to the wrapped string. + def method_missing(method, *args, &block) + result = @wrapped_string.__send__(method, *args, &block) + if /!$/.match?(method) + self if result + else + result.kind_of?(String) ? chars(result) : result + end + end + + # Returns +true+ if _obj_ responds to the given method. Private methods + # are included in the search only if the optional second parameter + # evaluates to +true+. + def respond_to_missing?(method, include_private) + @wrapped_string.respond_to?(method, include_private) + end + + # Returns +true+ when the proxy class can handle the string. Returns + # +false+ otherwise. + def self.consumes?(string) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Chars.consumes? is deprecated and will be + removed from Rails 6.1. Use string.is_utf8? instead. + MSG + + string.encoding == Encoding::UTF_8 + end + + # Works just like <tt>String#split</tt>, with the exception that the items + # in the resulting list are Chars instances instead of String. This makes + # chaining methods easier. + # + # 'Café périferôl'.mb_chars.split(/é/).map { |part| part.upcase.to_s } # => ["CAF", " P", "RIFERÔL"] + def split(*args) + @wrapped_string.split(*args).map { |i| self.class.new(i) } + end + + # Works like <tt>String#slice!</tt>, but returns an instance of + # Chars, or +nil+ if the string was not modified. The string will not be + # modified if the range given is out of bounds + # + # string = 'Welcome' + # string.mb_chars.slice!(3) # => #<ActiveSupport::Multibyte::Chars:0x000000038109b8 @wrapped_string="c"> + # string # => 'Welome' + # string.mb_chars.slice!(0..3) # => #<ActiveSupport::Multibyte::Chars:0x00000002eb80a0 @wrapped_string="Welo"> + # string # => 'me' + def slice!(*args) + string_sliced = @wrapped_string.slice!(*args) + if string_sliced + chars(string_sliced) + end + end + + # Reverses all characters in the string. + # + # 'Café'.mb_chars.reverse.to_s # => 'éfaC' + def reverse + chars(@wrapped_string.scan(/\X/).reverse.join) + end + + # Limits the byte size of the string to a number of bytes without breaking + # characters. Usable when the storage for a string is limited for some + # reason. + # + # 'こんにちは'.mb_chars.limit(7).to_s # => "こん" + def limit(limit) + truncate_bytes(limit, omission: nil) + end + + # Capitalizes the first letter of every word, when possible. + # + # "ÉL QUE SE ENTERÓ".mb_chars.titleize.to_s # => "Él Que Se Enteró" + # "日本語".mb_chars.titleize.to_s # => "日本語" + def titleize + chars(downcase.to_s.gsub(/\b('?\S)/u) { $1.upcase }) + end + alias_method :titlecase, :titleize + + # Returns the KC normalization of the string by default. NFKC is + # considered the best normalization form for passing strings to databases + # and validations. + # + # * <tt>form</tt> - The form you want to normalize in. Should be one of the following: + # <tt>:c</tt>, <tt>:kc</tt>, <tt>:d</tt>, or <tt>:kd</tt>. Default is + # ActiveSupport::Multibyte::Unicode.default_normalization_form + def normalize(form = nil) + form ||= Unicode.default_normalization_form + + # See https://www.unicode.org/reports/tr15, Table 1 + if alias_form = Unicode::NORMALIZATION_FORM_ALIASES[form] + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Chars#normalize is deprecated and will be + removed from Rails 6.1. Use #unicode_normalize(:#{alias_form}) instead. + MSG + + send(:unicode_normalize, alias_form) + else + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Chars#normalize is deprecated and will be + removed from Rails 6.1. Use #unicode_normalize instead. + MSG + + raise ArgumentError, "#{form} is not a valid normalization variant", caller + end + end + + # Performs canonical decomposition on all the characters. + # + # 'é'.length # => 2 + # 'é'.mb_chars.decompose.to_s.length # => 3 + def decompose + chars(Unicode.decompose(:canonical, @wrapped_string.codepoints.to_a).pack("U*")) + end + + # Performs composition on all the characters. + # + # 'é'.length # => 3 + # 'é'.mb_chars.compose.to_s.length # => 2 + def compose + chars(Unicode.compose(@wrapped_string.codepoints.to_a).pack("U*")) + end + + # Returns the number of grapheme clusters in the string. + # + # 'क्षि'.mb_chars.length # => 4 + # 'क्षि'.mb_chars.grapheme_length # => 3 + def grapheme_length + @wrapped_string.scan(/\X/).length + end + + # Replaces all ISO-8859-1 or CP1252 characters by their UTF-8 equivalent + # resulting in a valid UTF-8 string. + # + # Passing +true+ will forcibly tidy all bytes, assuming that the string's + # encoding is entirely CP1252 or ISO-8859-1. + def tidy_bytes(force = false) + chars(Unicode.tidy_bytes(@wrapped_string, force)) + end + + def as_json(options = nil) #:nodoc: + to_s.as_json(options) + end + + %w(reverse tidy_bytes).each do |method| + define_method("#{method}!") do |*args| + @wrapped_string = send(method, *args).to_s + self + end + end + + private + + def chars(string) + self.class.new(string) + end + end + end +end diff --git a/activesupport/lib/active_support/multibyte/unicode.rb b/activesupport/lib/active_support/multibyte/unicode.rb new file mode 100644 index 0000000000..ce8ecece69 --- /dev/null +++ b/activesupport/lib/active_support/multibyte/unicode.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +module ActiveSupport + module Multibyte + module Unicode + extend self + + # A list of all available normalization forms. + # See https://www.unicode.org/reports/tr15/tr15-29.html for more + # information about normalization. + NORMALIZATION_FORMS = [:c, :kc, :d, :kd] + + NORMALIZATION_FORM_ALIASES = { # :nodoc: + c: :nfc, + d: :nfd, + kc: :nfkc, + kd: :nfkd + } + + # The Unicode version that is supported by the implementation + UNICODE_VERSION = RbConfig::CONFIG["UNICODE_VERSION"] + + # The default normalization used for operations that require + # normalization. It can be set to any of the normalizations + # in NORMALIZATION_FORMS. + # + # ActiveSupport::Multibyte::Unicode.default_normalization_form = :c + attr_accessor :default_normalization_form + @default_normalization_form = :kc + + # Unpack the string at grapheme boundaries. Returns a list of character + # lists. + # + # Unicode.unpack_graphemes('क्षि') # => [[2325, 2381], [2359], [2367]] + # Unicode.unpack_graphemes('Café') # => [[67], [97], [102], [233]] + def unpack_graphemes(string) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Unicode#unpack_graphemes is deprecated and will be + removed from Rails 6.1. Use string.scan(/\X/).map(&:codepoints) instead. + MSG + + string.scan(/\X/).map(&:codepoints) + end + + # Reverse operation of unpack_graphemes. + # + # Unicode.pack_graphemes(Unicode.unpack_graphemes('क्षि')) # => 'क्षि' + def pack_graphemes(unpacked) + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Unicode#pack_graphemes is deprecated and will be + removed from Rails 6.1. Use array.flatten.pack("U*") instead. + MSG + + unpacked.flatten.pack("U*") + end + + # Decompose composed characters to the decomposed form. + def decompose(type, codepoints) + if type == :compatibility + codepoints.pack("U*").unicode_normalize(:nfkd).codepoints + else + codepoints.pack("U*").unicode_normalize(:nfd).codepoints + end + end + + # Compose decomposed characters to the composed form. + def compose(codepoints) + codepoints.pack("U*").unicode_normalize(:nfc).codepoints + end + + # Rubinius' String#scrub, however, doesn't support ASCII-incompatible chars. + if !defined?(Rubinius) + # Replaces all ISO-8859-1 or CP1252 characters by their UTF-8 equivalent + # resulting in a valid UTF-8 string. + # + # Passing +true+ will forcibly tidy all bytes, assuming that the string's + # encoding is entirely CP1252 or ISO-8859-1. + def tidy_bytes(string, force = false) + return string if string.empty? + return recode_windows1252_chars(string) if force + string.scrub { |bad| recode_windows1252_chars(bad) } + end + else + def tidy_bytes(string, force = false) + return string if string.empty? + return recode_windows1252_chars(string) if force + + # We can't transcode to the same format, so we choose a nearly-identical encoding. + # We're going to 'transcode' bytes from UTF-8 when possible, then fall back to + # CP1252 when we get errors. The final string will be 'converted' back to UTF-8 + # before returning. + reader = Encoding::Converter.new(Encoding::UTF_8, Encoding::UTF_16LE) + + source = string.dup + out = "".force_encoding(Encoding::UTF_16LE) + + loop do + reader.primitive_convert(source, out) + _, _, _, error_bytes, _ = reader.primitive_errinfo + break if error_bytes.nil? + out << error_bytes.encode(Encoding::UTF_16LE, Encoding::Windows_1252, invalid: :replace, undef: :replace) + end + + reader.finish + + out.encode!(Encoding::UTF_8) + end + end + + # Returns the KC normalization of the string by default. NFKC is + # considered the best normalization form for passing strings to databases + # and validations. + # + # * <tt>string</tt> - The string to perform normalization on. + # * <tt>form</tt> - The form you want to normalize in. Should be one of + # the following: <tt>:c</tt>, <tt>:kc</tt>, <tt>:d</tt>, or <tt>:kd</tt>. + # Default is ActiveSupport::Multibyte::Unicode.default_normalization_form. + def normalize(string, form = nil) + form ||= @default_normalization_form + + # See https://www.unicode.org/reports/tr15, Table 1 + if alias_form = NORMALIZATION_FORM_ALIASES[form] + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Unicode#normalize is deprecated and will be + removed from Rails 6.1. Use String#unicode_normalize(:#{alias_form}) instead. + MSG + + string.unicode_normalize(alias_form) + else + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Unicode#normalize is deprecated and will be + removed from Rails 6.1. Use String#unicode_normalize instead. + MSG + + raise ArgumentError, "#{form} is not a valid normalization variant", caller + end + end + + %w(downcase upcase swapcase).each do |method| + define_method(method) do |string| + ActiveSupport::Deprecation.warn(<<-MSG.squish) + ActiveSupport::Multibyte::Unicode##{method} is deprecated and + will be removed from Rails 6.1. Use String methods directly. + MSG + + string.send(method) + end + end + + private + + def recode_windows1252_chars(string) + string.encode(Encoding::UTF_8, Encoding::Windows_1252, invalid: :replace, undef: :replace) + end + end + end +end diff --git a/activesupport/lib/active_support/notifications.rb b/activesupport/lib/active_support/notifications.rb new file mode 100644 index 0000000000..0ff32bd810 --- /dev/null +++ b/activesupport/lib/active_support/notifications.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require "active_support/notifications/instrumenter" +require "active_support/notifications/fanout" +require "active_support/per_thread_registry" + +module ActiveSupport + # = Notifications + # + # <tt>ActiveSupport::Notifications</tt> provides an instrumentation API for + # Ruby. + # + # == Instrumenters + # + # To instrument an event you just need to do: + # + # ActiveSupport::Notifications.instrument('render', extra: :information) do + # render plain: 'Foo' + # end + # + # That first executes the block and then notifies all subscribers once done. + # + # In the example above +render+ is the name of the event, and the rest is called + # the _payload_. The payload is a mechanism that allows instrumenters to pass + # extra information to subscribers. Payloads consist of a hash whose contents + # are arbitrary and generally depend on the event. + # + # == Subscribers + # + # You can consume those events and the information they provide by registering + # a subscriber. + # + # ActiveSupport::Notifications.subscribe('render') do |name, start, finish, id, payload| + # name # => String, name of the event (such as 'render' from above) + # start # => Time, when the instrumented block started execution + # finish # => Time, when the instrumented block ended execution + # id # => String, unique ID for the instrumenter that fired the event + # payload # => Hash, the payload + # end + # + # For instance, let's store all "render" events in an array: + # + # events = [] + # + # ActiveSupport::Notifications.subscribe('render') do |*args| + # events << ActiveSupport::Notifications::Event.new(*args) + # end + # + # That code returns right away, you are just subscribing to "render" events. + # The block is saved and will be called whenever someone instruments "render": + # + # ActiveSupport::Notifications.instrument('render', extra: :information) do + # render plain: 'Foo' + # end + # + # event = events.first + # event.name # => "render" + # event.duration # => 10 (in milliseconds) + # event.payload # => { extra: :information } + # + # The block in the <tt>subscribe</tt> call gets the name of the event, start + # timestamp, end timestamp, a string with a unique identifier for that event's instrumenter + # (something like "535801666f04d0298cd6"), and a hash with the payload, in + # that order. + # + # If an exception happens during that particular instrumentation the payload will + # have a key <tt>:exception</tt> with an array of two elements as value: a string with + # the name of the exception class, and the exception message. + # The <tt>:exception_object</tt> key of the payload will have the exception + # itself as the value. + # + # As the previous example depicts, the class <tt>ActiveSupport::Notifications::Event</tt> + # is able to take the arguments as they come and provide an object-oriented + # interface to that data. + # + # It is also possible to pass an object which responds to <tt>call</tt> method + # as the second parameter to the <tt>subscribe</tt> method instead of a block: + # + # module ActionController + # class PageRequest + # def call(name, started, finished, unique_id, payload) + # Rails.logger.debug ['notification:', name, started, finished, unique_id, payload].join(' ') + # end + # end + # end + # + # ActiveSupport::Notifications.subscribe('process_action.action_controller', ActionController::PageRequest.new) + # + # resulting in the following output within the logs including a hash with the payload: + # + # notification: process_action.action_controller 2012-04-13 01:08:35 +0300 2012-04-13 01:08:35 +0300 af358ed7fab884532ec7 { + # controller: "Devise::SessionsController", + # action: "new", + # params: {"action"=>"new", "controller"=>"devise/sessions"}, + # format: :html, + # method: "GET", + # path: "/login/sign_in", + # status: 200, + # view_runtime: 279.3080806732178, + # db_runtime: 40.053 + # } + # + # You can also subscribe to all events whose name matches a certain regexp: + # + # ActiveSupport::Notifications.subscribe(/render/) do |*args| + # ... + # end + # + # and even pass no argument to <tt>subscribe</tt>, in which case you are subscribing + # to all events. + # + # == Temporary Subscriptions + # + # Sometimes you do not want to subscribe to an event for the entire life of + # the application. There are two ways to unsubscribe. + # + # WARNING: The instrumentation framework is designed for long-running subscribers, + # use this feature sparingly because it wipes some internal caches and that has + # a negative impact on performance. + # + # === Subscribe While a Block Runs + # + # You can subscribe to some event temporarily while some block runs. For + # example, in + # + # callback = lambda {|*args| ... } + # ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + # ... + # end + # + # the callback will be called for all "sql.active_record" events instrumented + # during the execution of the block. The callback is unsubscribed automatically + # after that. + # + # === Manual Unsubscription + # + # The +subscribe+ method returns a subscriber object: + # + # subscriber = ActiveSupport::Notifications.subscribe("render") do |*args| + # ... + # end + # + # To prevent that block from being called anymore, just unsubscribe passing + # that reference: + # + # ActiveSupport::Notifications.unsubscribe(subscriber) + # + # You can also unsubscribe by passing the name of the subscriber object. Note + # that this will unsubscribe all subscriptions with the given name: + # + # ActiveSupport::Notifications.unsubscribe("render") + # + # == Default Queue + # + # Notifications ships with a queue implementation that consumes and publishes events + # to all log subscribers. You can use any queue implementation you want. + # + module Notifications + class << self + attr_accessor :notifier + + def publish(name, *args) + notifier.publish(name, *args) + end + + def instrument(name, payload = {}) + if notifier.listening?(name) + instrumenter.instrument(name, payload) { yield payload if block_given? } + else + yield payload if block_given? + end + end + + # Subscribe to a given event name with the passed +block+. + # + # You can subscribe to events by passing a String to match exact event + # names, or by passing a Regexp to match all events that match a pattern. + # + # ActiveSupport::Notifications.subscribe(/render/) do |*args| + # @event = ActiveSupport::Notifications::Event.new(*args) + # end + # + # The +block+ will receive five parameters with information about the event: + # + # ActiveSupport::Notifications.subscribe('render') do |name, start, finish, id, payload| + # name # => String, name of the event (such as 'render' from above) + # start # => Time, when the instrumented block started execution + # finish # => Time, when the instrumented block ended execution + # id # => String, unique ID for the instrumenter that fired the event + # payload # => Hash, the payload + # end + # + # If the block passed to the method only takes one parameter, + # it will yield an event object to the block: + # + # ActiveSupport::Notifications.subscribe(/render/) do |event| + # @event = event + # end + def subscribe(*args, &block) + notifier.subscribe(*args, &block) + end + + def subscribed(callback, *args, &block) + subscriber = subscribe(*args, &callback) + yield + ensure + unsubscribe(subscriber) + end + + def unsubscribe(subscriber_or_name) + notifier.unsubscribe(subscriber_or_name) + end + + def instrumenter + InstrumentationRegistry.instance.instrumenter_for(notifier) + end + end + + # This class is a registry which holds all of the +Instrumenter+ objects + # in a particular thread local. To access the +Instrumenter+ object for a + # particular +notifier+, you can call the following method: + # + # InstrumentationRegistry.instrumenter_for(notifier) + # + # The instrumenters for multiple notifiers are held in a single instance of + # this class. + class InstrumentationRegistry # :nodoc: + extend ActiveSupport::PerThreadRegistry + + def initialize + @registry = {} + end + + def instrumenter_for(notifier) + @registry[notifier] ||= Instrumenter.new(notifier) + end + end + + self.notifier = Fanout.new + end +end diff --git a/activesupport/lib/active_support/notifications/fanout.rb b/activesupport/lib/active_support/notifications/fanout.rb new file mode 100644 index 0000000000..4e4ca70942 --- /dev/null +++ b/activesupport/lib/active_support/notifications/fanout.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require "mutex_m" +require "concurrent/map" + +module ActiveSupport + module Notifications + # This is a default queue implementation that ships with Notifications. + # It just pushes events to all registered log subscribers. + # + # This class is thread safe. All methods are reentrant. + class Fanout + include Mutex_m + + def initialize + @subscribers = [] + @listeners_for = Concurrent::Map.new + super + end + + def subscribe(pattern = nil, block = Proc.new) + subscriber = Subscribers.new pattern, block + synchronize do + @subscribers << subscriber + @listeners_for.clear + end + subscriber + end + + def unsubscribe(subscriber_or_name) + synchronize do + case subscriber_or_name + when String + @subscribers.reject! { |s| s.matches?(subscriber_or_name) } + else + @subscribers.delete(subscriber_or_name) + end + + @listeners_for.clear + end + end + + def start(name, id, payload) + listeners_for(name).each { |s| s.start(name, id, payload) } + end + + def finish(name, id, payload, listeners = listeners_for(name)) + listeners.each { |s| s.finish(name, id, payload) } + end + + def publish(name, *args) + listeners_for(name).each { |s| s.publish(name, *args) } + end + + def listeners_for(name) + # this is correctly done double-checked locking (Concurrent::Map's lookups have volatile semantics) + @listeners_for[name] || synchronize do + # use synchronisation when accessing @subscribers + @listeners_for[name] ||= @subscribers.select { |s| s.subscribed_to?(name) } + end + end + + def listening?(name) + listeners_for(name).any? + end + + # This is a sync queue, so there is no waiting. + def wait + end + + module Subscribers # :nodoc: + def self.new(pattern, listener) + subscriber_class = Timed + + if listener.respond_to?(:start) && listener.respond_to?(:finish) + subscriber_class = Evented + else + # Doing all this to detect a block like `proc { |x| }` vs + # `proc { |*x| }` or `proc { |**x| }` + if listener.respond_to?(:parameters) + params = listener.parameters + if params.length == 1 && params.first.first == :opt + subscriber_class = EventObject + end + end + end + + wrap_all pattern, subscriber_class.new(pattern, listener) + end + + def self.event_object_subscriber(pattern, block) + wrap_all pattern, EventObject.new(pattern, block) + end + + def self.wrap_all(pattern, subscriber) + unless pattern + AllMessages.new(subscriber) + else + subscriber + end + end + + class Evented #:nodoc: + def initialize(pattern, delegate) + @pattern = pattern + @delegate = delegate + @can_publish = delegate.respond_to?(:publish) + end + + def publish(name, *args) + if @can_publish + @delegate.publish name, *args + end + end + + def start(name, id, payload) + @delegate.start name, id, payload + end + + def finish(name, id, payload) + @delegate.finish name, id, payload + end + + def subscribed_to?(name) + @pattern === name + end + + def matches?(name) + @pattern && @pattern === name + end + end + + class Timed < Evented # :nodoc: + def publish(name, *args) + @delegate.call name, *args + end + + def start(name, id, payload) + timestack = Thread.current[:_timestack] ||= [] + timestack.push Time.now + end + + def finish(name, id, payload) + timestack = Thread.current[:_timestack] + started = timestack.pop + @delegate.call(name, started, Time.now, id, payload) + end + end + + class EventObject < Evented + def start(name, id, payload) + stack = Thread.current[:_event_stack] ||= [] + event = build_event name, id, payload + event.start! + stack.push event + end + + def finish(name, id, payload) + stack = Thread.current[:_event_stack] + event = stack.pop + event.finish! + @delegate.call event + end + + private + def build_event(name, id, payload) + ActiveSupport::Notifications::Event.new name, nil, nil, id, payload + end + end + + class AllMessages # :nodoc: + def initialize(delegate) + @delegate = delegate + end + + def start(name, id, payload) + @delegate.start name, id, payload + end + + def finish(name, id, payload) + @delegate.finish name, id, payload + end + + def publish(name, *args) + @delegate.publish name, *args + end + + def subscribed_to?(name) + true + end + + alias :matches? :=== + end + end + end + end +end diff --git a/activesupport/lib/active_support/notifications/instrumenter.rb b/activesupport/lib/active_support/notifications/instrumenter.rb new file mode 100644 index 0000000000..125c06f37a --- /dev/null +++ b/activesupport/lib/active_support/notifications/instrumenter.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "securerandom" + +module ActiveSupport + module Notifications + # Instrumenters are stored in a thread local. + class Instrumenter + attr_reader :id + + def initialize(notifier) + @id = unique_id + @notifier = notifier + end + + # Instrument the given block by measuring the time taken to execute it + # and publish it. Notice that events get sent even if an error occurs + # in the passed-in block. + def instrument(name, payload = {}) + # some of the listeners might have state + listeners_state = start name, payload + begin + yield payload + rescue Exception => e + payload[:exception] = [e.class.name, e.message] + payload[:exception_object] = e + raise e + ensure + finish_with_state listeners_state, name, payload + end + end + + # Send a start notification with +name+ and +payload+. + def start(name, payload) + @notifier.start name, @id, payload + end + + # Send a finish notification with +name+ and +payload+. + def finish(name, payload) + @notifier.finish name, @id, payload + end + + def finish_with_state(listeners_state, name, payload) + @notifier.finish name, @id, payload, listeners_state + end + + private + + def unique_id + SecureRandom.hex(10) + end + end + + class Event + attr_reader :name, :time, :end, :transaction_id, :payload, :children + + def self.clock_gettime_supported? # :nodoc: + defined?(Process::CLOCK_PROCESS_CPUTIME_ID) && + !Gem.win_platform? + end + private_class_method :clock_gettime_supported? + + def initialize(name, start, ending, transaction_id, payload) + @name = name + @payload = payload.dup + @time = start + @transaction_id = transaction_id + @end = ending + @children = [] + @duration = nil + @cpu_time_start = nil + @cpu_time_finish = nil + @allocation_count_start = 0 + @allocation_count_finish = 0 + end + + # Record information at the time this event starts + def start! + @time = now + @cpu_time_start = now_cpu + @allocation_count_start = now_allocations + end + + # Record information at the time this event finishes + def finish! + @cpu_time_finish = now_cpu + @end = now + @allocation_count_finish = now_allocations + end + + def end=(ending) + ActiveSupport::Deprecation.deprecation_warning(:end=, :finish!) + @end = ending + end + + # Returns the CPU time (in milliseconds) passed since the call to + # +start!+ and the call to +finish!+ + def cpu_time + (@cpu_time_finish - @cpu_time_start) * 1000 + end + + # Returns the idle time time (in milliseconds) passed since the call to + # +start!+ and the call to +finish!+ + def idle_time + duration - cpu_time + end + + # Returns the number of allocations made since the call to +start!+ and + # the call to +finish!+ + def allocations + @allocation_count_finish - @allocation_count_start + end + + # Returns the difference in milliseconds between when the execution of the + # event started and when it ended. + # + # ActiveSupport::Notifications.subscribe('wait') do |*args| + # @event = ActiveSupport::Notifications::Event.new(*args) + # end + # + # ActiveSupport::Notifications.instrument('wait') do + # sleep 1 + # end + # + # @event.duration # => 1000.138 + def duration + @duration ||= 1000.0 * (self.end - time) + end + + def <<(event) + @children << event + end + + def parent_of?(event) + @children.include? event + end + + private + def now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + if clock_gettime_supported? + def now_cpu + Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) + end + else + def now_cpu + 0 + end + end + + if defined?(JRUBY_VERSION) + def now_allocations + 0 + end + else + def now_allocations + GC.stat :total_allocated_objects + end + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper.rb b/activesupport/lib/active_support/number_helper.rb new file mode 100644 index 0000000000..d19a2f64d4 --- /dev/null +++ b/activesupport/lib/active_support/number_helper.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require "active_support/dependencies/autoload" + +module ActiveSupport + module NumberHelper + extend ActiveSupport::Autoload + + eager_autoload do + autoload :NumberConverter + autoload :RoundingHelper + autoload :NumberToRoundedConverter + autoload :NumberToDelimitedConverter + autoload :NumberToHumanConverter + autoload :NumberToHumanSizeConverter + autoload :NumberToPhoneConverter + autoload :NumberToCurrencyConverter + autoload :NumberToPercentageConverter + end + + extend self + + # Formats a +number+ into a phone number (US by default e.g., (555) + # 123-9876). You can customize the format in the +options+ hash. + # + # ==== Options + # + # * <tt>:area_code</tt> - Adds parentheses around the area code. + # * <tt>:delimiter</tt> - Specifies the delimiter to use + # (defaults to "-"). + # * <tt>:extension</tt> - Specifies an extension to add to the + # end of the generated number. + # * <tt>:country_code</tt> - Sets the country code for the phone + # number. + # * <tt>:pattern</tt> - Specifies how the number is divided into three + # groups with the custom regexp to override the default format. + # ==== Examples + # + # number_to_phone(5551234) # => "555-1234" + # number_to_phone('5551234') # => "555-1234" + # number_to_phone(1235551234) # => "123-555-1234" + # number_to_phone(1235551234, area_code: true) # => "(123) 555-1234" + # number_to_phone(1235551234, delimiter: ' ') # => "123 555 1234" + # number_to_phone(1235551234, area_code: true, extension: 555) # => "(123) 555-1234 x 555" + # number_to_phone(1235551234, country_code: 1) # => "+1-123-555-1234" + # number_to_phone('123a456') # => "123a456" + # + # number_to_phone(1235551234, country_code: 1, extension: 1343, delimiter: '.') + # # => "+1.123.555.1234 x 1343" + # + # number_to_phone(75561234567, pattern: /(\d{1,4})(\d{4})(\d{4})$/, area_code: true) + # # => "(755) 6123-4567" + # number_to_phone(13312345678, pattern: /(\d{3})(\d{4})(\d{4})$/) + # # => "133-1234-5678" + def number_to_phone(number, options = {}) + NumberToPhoneConverter.convert(number, options) + end + + # Formats a +number+ into a currency string (e.g., $13.65). You + # can customize the format in the +options+ hash. + # + # The currency unit and number formatting of the current locale will be used + # unless otherwise specified in the provided options. No currency conversion + # is performed. If the user is given a way to change their locale, they will + # also be able to change the relative value of the currency displayed with + # this helper. If your application will ever support multiple locales, you + # may want to specify a constant <tt>:locale</tt> option or consider + # using a library capable of currency conversion. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the level of precision (defaults + # to 2). + # * <tt>:unit</tt> - Sets the denomination of the currency + # (defaults to "$"). + # * <tt>:separator</tt> - Sets the separator between the units + # (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ","). + # * <tt>:format</tt> - Sets the format for non-negative numbers + # (defaults to "%u%n"). Fields are <tt>%u</tt> for the + # currency, and <tt>%n</tt> for the number. + # * <tt>:negative_format</tt> - Sets the format for negative + # numbers (defaults to prepending a hyphen to the formatted + # number given by <tt>:format</tt>). Accepts the same fields + # than <tt>:format</tt>, except <tt>%n</tt> is here the + # absolute value of the number. + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +false+). + # + # ==== Examples + # + # number_to_currency(1234567890.50) # => "$1,234,567,890.50" + # number_to_currency(1234567890.506) # => "$1,234,567,890.51" + # number_to_currency(1234567890.506, precision: 3) # => "$1,234,567,890.506" + # number_to_currency(1234567890.506, locale: :fr) # => "1 234 567 890,51 €" + # number_to_currency('123a456') # => "$123a456" + # + # number_to_currency(-1234567890.50, negative_format: '(%u%n)') + # # => "($1,234,567,890.50)" + # number_to_currency(1234567890.50, unit: '£', separator: ',', delimiter: '') + # # => "£1234567890,50" + # number_to_currency(1234567890.50, unit: '£', separator: ',', delimiter: '', format: '%n %u') + # # => "1234567890,50 £" + # number_to_currency(1234567890.50, strip_insignificant_zeros: true) + # # => "$1,234,567,890.5" + def number_to_currency(number, options = {}) + NumberToCurrencyConverter.convert(number, options) + end + + # Formats a +number+ as a percentage string (e.g., 65%). You can + # customize the format in the +options+ hash. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). Keeps the number's precision if +nil+. + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +false+). + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +false+). + # * <tt>:format</tt> - Specifies the format of the percentage + # string The number field is <tt>%n</tt> (defaults to "%n%"). + # + # ==== Examples + # + # number_to_percentage(100) # => "100.000%" + # number_to_percentage('98') # => "98.000%" + # number_to_percentage(100, precision: 0) # => "100%" + # number_to_percentage(1000, delimiter: '.', separator: ',') # => "1.000,000%" + # number_to_percentage(302.24398923423, precision: 5) # => "302.24399%" + # number_to_percentage(1000, locale: :fr) # => "1000,000%" + # number_to_percentage(1000, precision: nil) # => "1000%" + # number_to_percentage('98a') # => "98a%" + # number_to_percentage(100, format: '%n %') # => "100.000 %" + def number_to_percentage(number, options = {}) + NumberToPercentageConverter.convert(number, options) + end + + # Formats a +number+ with grouped thousands using +delimiter+ + # (e.g., 12,324). You can customize the format in the +options+ + # hash. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ","). + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter_pattern</tt> - Sets a custom regular expression used for + # deriving the placement of delimiter. Helpful when using currency formats + # like INR. + # + # ==== Examples + # + # number_to_delimited(12345678) # => "12,345,678" + # number_to_delimited('123456') # => "123,456" + # number_to_delimited(12345678.05) # => "12,345,678.05" + # number_to_delimited(12345678, delimiter: '.') # => "12.345.678" + # number_to_delimited(12345678, delimiter: ',') # => "12,345,678" + # number_to_delimited(12345678.05, separator: ' ') # => "12,345,678 05" + # number_to_delimited(12345678.05, locale: :fr) # => "12 345 678,05" + # number_to_delimited('112a') # => "112a" + # number_to_delimited(98765432.98, delimiter: ' ', separator: ',') + # # => "98 765 432,98" + # number_to_delimited("123456.78", + # delimiter_pattern: /(\d+?)(?=(\d\d)+(\d)(?!\d))/) + # # => "1,23,456.78" + def number_to_delimited(number, options = {}) + NumberToDelimitedConverter.convert(number, options) + end + + # Formats a +number+ with the specified level of + # <tt>:precision</tt> (e.g., 112.32 has a precision of 2 if + # +:significant+ is +false+, and 5 if +:significant+ is +true+). + # You can customize the format in the +options+ hash. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). Keeps the number's precision if +nil+. + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +false+). + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +false+). + # + # ==== Examples + # + # number_to_rounded(111.2345) # => "111.235" + # number_to_rounded(111.2345, precision: 2) # => "111.23" + # number_to_rounded(13, precision: 5) # => "13.00000" + # number_to_rounded(389.32314, precision: 0) # => "389" + # number_to_rounded(111.2345, significant: true) # => "111" + # number_to_rounded(111.2345, precision: 1, significant: true) # => "100" + # number_to_rounded(13, precision: 5, significant: true) # => "13.000" + # number_to_rounded(13, precision: nil) # => "13" + # number_to_rounded(111.234, locale: :fr) # => "111,234" + # + # number_to_rounded(13, precision: 5, significant: true, strip_insignificant_zeros: true) + # # => "13" + # + # number_to_rounded(389.32314, precision: 4, significant: true) # => "389.3" + # number_to_rounded(1111.2345, precision: 2, separator: ',', delimiter: '.') + # # => "1.111,23" + def number_to_rounded(number, options = {}) + NumberToRoundedConverter.convert(number, options) + end + + # Formats the bytes in +number+ into a more understandable + # representation (e.g., giving it 1500 yields 1.5 KB). This + # method is useful for reporting file sizes to users. You can + # customize the format in the +options+ hash. + # + # See <tt>number_to_human</tt> if you want to pretty-print a + # generic number. + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +true+) + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +true+) + # + # ==== Examples + # + # number_to_human_size(123) # => "123 Bytes" + # number_to_human_size(1234) # => "1.21 KB" + # number_to_human_size(12345) # => "12.1 KB" + # number_to_human_size(1234567) # => "1.18 MB" + # number_to_human_size(1234567890) # => "1.15 GB" + # number_to_human_size(1234567890123) # => "1.12 TB" + # number_to_human_size(1234567890123456) # => "1.1 PB" + # number_to_human_size(1234567890123456789) # => "1.07 EB" + # number_to_human_size(1234567, precision: 2) # => "1.2 MB" + # number_to_human_size(483989, precision: 2) # => "470 KB" + # number_to_human_size(1234567, precision: 2, separator: ',') # => "1,2 MB" + # number_to_human_size(1234567890123, precision: 5) # => "1.1228 TB" + # number_to_human_size(524288000, precision: 5) # => "500 MB" + def number_to_human_size(number, options = {}) + NumberToHumanSizeConverter.convert(number, options) + end + + # Pretty prints (formats and approximates) a number in a way it + # is more readable by humans (eg.: 1200000000 becomes "1.2 + # Billion"). This is useful for numbers that can get very large + # (and too hard to read). + # + # See <tt>number_to_human_size</tt> if you want to print a file + # size. + # + # You can also define your own unit-quantifier names if you want + # to use other decimal units (eg.: 1500 becomes "1.5 + # kilometers", 0.150 becomes "150 milliliters", etc). You may + # define a wide range of unit quantifiers, even fractional ones + # (centi, deci, mili, etc). + # + # ==== Options + # + # * <tt>:locale</tt> - Sets the locale to be used for formatting + # (defaults to current locale). + # * <tt>:precision</tt> - Sets the precision of the number + # (defaults to 3). + # * <tt>:significant</tt> - If +true+, precision will be the number + # of significant_digits. If +false+, the number of fractional + # digits (defaults to +true+) + # * <tt>:separator</tt> - Sets the separator between the + # fractional and integer digits (defaults to "."). + # * <tt>:delimiter</tt> - Sets the thousands delimiter (defaults + # to ""). + # * <tt>:strip_insignificant_zeros</tt> - If +true+ removes + # insignificant zeros after the decimal separator (defaults to + # +true+) + # * <tt>:units</tt> - A Hash of unit quantifier names. Or a + # string containing an i18n scope where to find this hash. It + # might have the following keys: + # * *integers*: <tt>:unit</tt>, <tt>:ten</tt>, + # <tt>:hundred</tt>, <tt>:thousand</tt>, <tt>:million</tt>, + # <tt>:billion</tt>, <tt>:trillion</tt>, + # <tt>:quadrillion</tt> + # * *fractionals*: <tt>:deci</tt>, <tt>:centi</tt>, + # <tt>:mili</tt>, <tt>:micro</tt>, <tt>:nano</tt>, + # <tt>:pico</tt>, <tt>:femto</tt> + # * <tt>:format</tt> - Sets the format of the output string + # (defaults to "%n %u"). The field types are: + # * %u - The quantifier (ex.: 'thousand') + # * %n - The number + # + # ==== Examples + # + # number_to_human(123) # => "123" + # number_to_human(1234) # => "1.23 Thousand" + # number_to_human(12345) # => "12.3 Thousand" + # number_to_human(1234567) # => "1.23 Million" + # number_to_human(1234567890) # => "1.23 Billion" + # number_to_human(1234567890123) # => "1.23 Trillion" + # number_to_human(1234567890123456) # => "1.23 Quadrillion" + # number_to_human(1234567890123456789) # => "1230 Quadrillion" + # number_to_human(489939, precision: 2) # => "490 Thousand" + # number_to_human(489939, precision: 4) # => "489.9 Thousand" + # number_to_human(1234567, precision: 4, + # significant: false) # => "1.2346 Million" + # number_to_human(1234567, precision: 1, + # separator: ',', + # significant: false) # => "1,2 Million" + # + # number_to_human(500000000, precision: 5) # => "500 Million" + # number_to_human(12345012345, significant: false) # => "12.345 Billion" + # + # Non-significant zeros after the decimal separator are stripped + # out by default (set <tt>:strip_insignificant_zeros</tt> to + # +false+ to change that): + # + # number_to_human(12.00001) # => "12" + # number_to_human(12.00001, strip_insignificant_zeros: false) # => "12.0" + # + # ==== Custom Unit Quantifiers + # + # You can also use your own custom unit quantifiers: + # number_to_human(500000, units: { unit: 'ml', thousand: 'lt' }) # => "500 lt" + # + # If in your I18n locale you have: + # + # distance: + # centi: + # one: "centimeter" + # other: "centimeters" + # unit: + # one: "meter" + # other: "meters" + # thousand: + # one: "kilometer" + # other: "kilometers" + # billion: "gazillion-distance" + # + # Then you could do: + # + # number_to_human(543934, units: :distance) # => "544 kilometers" + # number_to_human(54393498, units: :distance) # => "54400 kilometers" + # number_to_human(54393498000, units: :distance) # => "54.4 gazillion-distance" + # number_to_human(343, units: :distance, precision: 1) # => "300 meters" + # number_to_human(1, units: :distance) # => "1 meter" + # number_to_human(0.34, units: :distance) # => "34 centimeters" + def number_to_human(number, options = {}) + NumberToHumanConverter.convert(number, options) + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_converter.rb b/activesupport/lib/active_support/number_helper/number_converter.rb new file mode 100644 index 0000000000..06ba797a13 --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_converter.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "active_support/core_ext/big_decimal/conversions" +require "active_support/core_ext/object/blank" +require "active_support/core_ext/hash/keys" +require "active_support/i18n" +require "active_support/core_ext/class/attribute" + +module ActiveSupport + module NumberHelper + class NumberConverter # :nodoc: + # Default and i18n option namespace per class + class_attribute :namespace + + # Does the object need a number that is a valid float? + class_attribute :validate_float + + attr_reader :number, :opts + + DEFAULTS = { + # Used in number_to_delimited + # These are also the defaults for 'currency', 'percentage', 'precision', and 'human' + format: { + # Sets the separator between the units, for more precision (e.g. 1.0 / 2.0 == 0.5) + separator: ".", + # Delimits thousands (e.g. 1,000,000 is a million) (always in groups of three) + delimiter: ",", + # Number of decimals, behind the separator (the number 1 with a precision of 2 gives: 1.00) + precision: 3, + # If set to true, precision will mean the number of significant digits instead + # of the number of decimal digits (1234 with precision 2 becomes 1200, 1.23543 becomes 1.2) + significant: false, + # If set, the zeros after the decimal separator will always be stripped (eg.: 1.200 will be 1.2) + strip_insignificant_zeros: false + }, + + # Used in number_to_currency + currency: { + format: { + format: "%u%n", + negative_format: "-%u%n", + unit: "$", + # These five are to override number.format and are optional + separator: ".", + delimiter: ",", + precision: 2, + significant: false, + strip_insignificant_zeros: false + } + }, + + # Used in number_to_percentage + percentage: { + format: { + delimiter: "", + format: "%n%" + } + }, + + # Used in number_to_rounded + precision: { + format: { + delimiter: "" + } + }, + + # Used in number_to_human_size and number_to_human + human: { + format: { + # These five are to override number.format and are optional + delimiter: "", + precision: 3, + significant: true, + strip_insignificant_zeros: true + }, + # Used in number_to_human_size + storage_units: { + # Storage units output formatting. + # %u is the storage unit, %n is the number (default: 2 MB) + format: "%n %u", + units: { + byte: "Bytes", + kb: "KB", + mb: "MB", + gb: "GB", + tb: "TB" + } + }, + # Used in number_to_human + decimal_units: { + format: "%n %u", + # Decimal units output formatting + # By default we will only quantify some of the exponents + # but the commented ones might be defined or overridden + # by the user. + units: { + # femto: Quadrillionth + # pico: Trillionth + # nano: Billionth + # micro: Millionth + # mili: Thousandth + # centi: Hundredth + # deci: Tenth + unit: "", + # ten: + # one: Ten + # other: Tens + # hundred: Hundred + thousand: "Thousand", + million: "Million", + billion: "Billion", + trillion: "Trillion", + quadrillion: "Quadrillion" + } + } + } + } + + def self.convert(number, options) + new(number, options).execute + end + + def initialize(number, options) + @number = number + @opts = options.symbolize_keys + end + + def execute + if !number + nil + elsif validate_float? && !valid_float? + number + else + convert + end + end + + private + + def options + @options ||= format_options.merge(opts) + end + + def format_options + default_format_options.merge!(i18n_format_options) + end + + def default_format_options + options = DEFAULTS[:format].dup + options.merge!(DEFAULTS[namespace][:format]) if namespace + options + end + + def i18n_format_options + locale = opts[:locale] + options = I18n.translate(:'number.format', locale: locale, default: {}).dup + + if namespace + options.merge!(I18n.translate(:"number.#{namespace}.format", locale: locale, default: {})) + end + + options + end + + def translate_number_value_with_default(key, i18n_options = {}) + I18n.translate(key, { default: default_value(key), scope: :number }.merge!(i18n_options)) + end + + def translate_in_locale(key, i18n_options = {}) + translate_number_value_with_default(key, { locale: options[:locale] }.merge(i18n_options)) + end + + def default_value(key) + key.split(".").reduce(DEFAULTS) { |defaults, k| defaults[k.to_sym] } + end + + def valid_float? + Float(number) + rescue ArgumentError, TypeError + false + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_to_currency_converter.rb b/activesupport/lib/active_support/number_helper/number_to_currency_converter.rb new file mode 100644 index 0000000000..0e8ae82dd5 --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_to_currency_converter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "active_support/number_helper/number_converter" + +module ActiveSupport + module NumberHelper + class NumberToCurrencyConverter < NumberConverter # :nodoc: + self.namespace = :currency + + def convert + number = self.number.to_s.strip + format = options[:format] + + if number.to_f.negative? + format = options[:negative_format] + number = absolute_value(number) + end + + rounded_number = NumberToRoundedConverter.convert(number, options) + format.gsub("%n", rounded_number).gsub("%u", options[:unit]) + end + + private + + def absolute_value(number) + number.respond_to?(:abs) ? number.abs : number.sub(/\A-/, "") + end + + def options + @options ||= begin + defaults = default_format_options.merge(i18n_opts) + # Override negative format if format options are given + defaults[:negative_format] = "-#{opts[:format]}" if opts[:format] + defaults.merge!(opts) + end + end + + def i18n_opts + # Set International negative format if it does not exist + i18n = i18n_format_options + i18n[:negative_format] ||= "-#{i18n[:format]}" if i18n[:format] + i18n + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_to_delimited_converter.rb b/activesupport/lib/active_support/number_helper/number_to_delimited_converter.rb new file mode 100644 index 0000000000..467a580a2e --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_to_delimited_converter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "active_support/number_helper/number_converter" + +module ActiveSupport + module NumberHelper + class NumberToDelimitedConverter < NumberConverter #:nodoc: + self.validate_float = true + + DEFAULT_DELIMITER_REGEX = /(\d)(?=(\d\d\d)+(?!\d))/ + + def convert + parts.join(options[:separator]) + end + + private + + def parts + left, right = number.to_s.split(".") + left.gsub!(delimiter_pattern) do |digit_to_delimit| + "#{digit_to_delimit}#{options[:delimiter]}" + end + [left, right].compact + end + + def delimiter_pattern + options.fetch(:delimiter_pattern, DEFAULT_DELIMITER_REGEX) + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_to_human_converter.rb b/activesupport/lib/active_support/number_helper/number_to_human_converter.rb new file mode 100644 index 0000000000..494408fc01 --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_to_human_converter.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "active_support/number_helper/number_converter" + +module ActiveSupport + module NumberHelper + class NumberToHumanConverter < NumberConverter # :nodoc: + DECIMAL_UNITS = { 0 => :unit, 1 => :ten, 2 => :hundred, 3 => :thousand, 6 => :million, 9 => :billion, 12 => :trillion, 15 => :quadrillion, + -1 => :deci, -2 => :centi, -3 => :mili, -6 => :micro, -9 => :nano, -12 => :pico, -15 => :femto } + INVERTED_DECIMAL_UNITS = DECIMAL_UNITS.invert + + self.namespace = :human + self.validate_float = true + + def convert # :nodoc: + @number = RoundingHelper.new(options).round(number) + @number = Float(number) + + # for backwards compatibility with those that didn't add strip_insignificant_zeros to their locale files + unless options.key?(:strip_insignificant_zeros) + options[:strip_insignificant_zeros] = true + end + + units = opts[:units] + exponent = calculate_exponent(units) + @number = number / (10**exponent) + + rounded_number = NumberToRoundedConverter.convert(number, options) + unit = determine_unit(units, exponent) + format.gsub("%n", rounded_number).gsub("%u", unit).strip + end + + private + + def format + options[:format] || translate_in_locale("human.decimal_units.format") + end + + def determine_unit(units, exponent) + exp = DECIMAL_UNITS[exponent] + case units + when Hash + units[exp] || "" + when String, Symbol + I18n.translate("#{units}.#{exp}", locale: options[:locale], count: number.to_i) + else + translate_in_locale("human.decimal_units.units.#{exp}", count: number.to_i) + end + end + + def calculate_exponent(units) + exponent = number != 0 ? Math.log10(number.abs).floor : 0 + unit_exponents(units).find { |e| exponent >= e } || 0 + end + + def unit_exponents(units) + case units + when Hash + units + when String, Symbol + I18n.translate(units.to_s, locale: options[:locale], raise: true) + when nil + translate_in_locale("human.decimal_units.units", raise: true) + else + raise ArgumentError, ":units must be a Hash or String translation scope." + end.keys.map { |e_name| INVERTED_DECIMAL_UNITS[e_name] }.sort_by(&:-@) + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_to_human_size_converter.rb b/activesupport/lib/active_support/number_helper/number_to_human_size_converter.rb new file mode 100644 index 0000000000..91262fa656 --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_to_human_size_converter.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "active_support/number_helper/number_converter" + +module ActiveSupport + module NumberHelper + class NumberToHumanSizeConverter < NumberConverter #:nodoc: + STORAGE_UNITS = [:byte, :kb, :mb, :gb, :tb, :pb, :eb] + + self.namespace = :human + self.validate_float = true + + def convert + @number = Float(number) + + # for backwards compatibility with those that didn't add strip_insignificant_zeros to their locale files + unless options.key?(:strip_insignificant_zeros) + options[:strip_insignificant_zeros] = true + end + + if smaller_than_base? + number_to_format = number.to_i.to_s + else + human_size = number / (base**exponent) + number_to_format = NumberToRoundedConverter.convert(human_size, options) + end + conversion_format.gsub("%n", number_to_format).gsub("%u", unit) + end + + private + + def conversion_format + translate_number_value_with_default("human.storage_units.format", locale: options[:locale], raise: true) + end + + def unit + translate_number_value_with_default(storage_unit_key, locale: options[:locale], count: number.to_i, raise: true) + end + + def storage_unit_key + key_end = smaller_than_base? ? "byte" : STORAGE_UNITS[exponent] + "human.storage_units.units.#{key_end}" + end + + def exponent + max = STORAGE_UNITS.size - 1 + exp = (Math.log(number) / Math.log(base)).to_i + exp = max if exp > max # avoid overflow for the highest unit + exp + end + + def smaller_than_base? + number.to_i < base + end + + def base + 1024 + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_to_percentage_converter.rb b/activesupport/lib/active_support/number_helper/number_to_percentage_converter.rb new file mode 100644 index 0000000000..0c2e190f8a --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_to_percentage_converter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "active_support/number_helper/number_converter" + +module ActiveSupport + module NumberHelper + class NumberToPercentageConverter < NumberConverter # :nodoc: + self.namespace = :percentage + + def convert + rounded_number = NumberToRoundedConverter.convert(number, options) + options[:format].gsub("%n", rounded_number) + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_to_phone_converter.rb b/activesupport/lib/active_support/number_helper/number_to_phone_converter.rb new file mode 100644 index 0000000000..d5e72981b4 --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_to_phone_converter.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "active_support/number_helper/number_converter" + +module ActiveSupport + module NumberHelper + class NumberToPhoneConverter < NumberConverter #:nodoc: + def convert + str = country_code(opts[:country_code]).dup + str << convert_to_phone_number(number.to_s.strip) + str << phone_ext(opts[:extension]) + end + + private + + def convert_to_phone_number(number) + if opts[:area_code] + convert_with_area_code(number) + else + convert_without_area_code(number) + end + end + + def convert_with_area_code(number) + default_pattern = /(\d{1,3})(\d{3})(\d{4}$)/ + number.gsub!(regexp_pattern(default_pattern), + "(\\1) \\2#{delimiter}\\3") + number + end + + def convert_without_area_code(number) + default_pattern = /(\d{0,3})(\d{3})(\d{4})$/ + number.gsub!(regexp_pattern(default_pattern), + "\\1#{delimiter}\\2#{delimiter}\\3") + number.slice!(0, 1) if start_with_delimiter?(number) + number + end + + def start_with_delimiter?(number) + delimiter.present? && number.start_with?(delimiter) + end + + def delimiter + opts[:delimiter] || "-" + end + + def country_code(code) + code.blank? ? "" : "+#{code}#{delimiter}" + end + + def phone_ext(ext) + ext.blank? ? "" : " x #{ext}" + end + + def regexp_pattern(default_pattern) + opts.fetch :pattern, default_pattern + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/number_to_rounded_converter.rb b/activesupport/lib/active_support/number_helper/number_to_rounded_converter.rb new file mode 100644 index 0000000000..6ceb9a572e --- /dev/null +++ b/activesupport/lib/active_support/number_helper/number_to_rounded_converter.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "active_support/number_helper/number_converter" + +module ActiveSupport + module NumberHelper + class NumberToRoundedConverter < NumberConverter # :nodoc: + self.namespace = :precision + self.validate_float = true + + def convert + helper = RoundingHelper.new(options) + rounded_number = helper.round(number) + + if precision = options[:precision] + if options[:significant] && precision > 0 + digits = helper.digit_count(rounded_number) + precision -= digits + precision = 0 if precision < 0 # don't let it be negative + end + + formatted_string = + if BigDecimal === rounded_number && rounded_number.finite? + s = rounded_number.to_s("F") + s << "0" * precision + a, b = s.split(".", 2) + a << "." + a << b[0, precision] + else + "%00.#{precision}f" % rounded_number + end + else + formatted_string = rounded_number + end + + delimited_number = NumberToDelimitedConverter.convert(formatted_string, options) + format_number(delimited_number) + end + + private + + def strip_insignificant_zeros + options[:strip_insignificant_zeros] + end + + def format_number(number) + if strip_insignificant_zeros + escaped_separator = Regexp.escape(options[:separator]) + number.sub(/(#{escaped_separator})(\d*[1-9])?0+\z/, '\1\2').sub(/#{escaped_separator}\z/, "") + else + number + end + end + end + end +end diff --git a/activesupport/lib/active_support/number_helper/rounding_helper.rb b/activesupport/lib/active_support/number_helper/rounding_helper.rb new file mode 100644 index 0000000000..2ad8d49c4e --- /dev/null +++ b/activesupport/lib/active_support/number_helper/rounding_helper.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ActiveSupport + module NumberHelper + class RoundingHelper # :nodoc: + attr_reader :options + + def initialize(options) + @options = options + end + + def round(number) + return number unless precision + number = convert_to_decimal(number) + if significant && precision > 0 + round_significant(number) + else + round_without_significant(number) + end + end + + def digit_count(number) + return 1 if number.zero? + (Math.log10(absolute_number(number)) + 1).floor + end + + private + def round_without_significant(number) + number = number.round(precision) + number = number.to_i if precision == 0 && number.finite? + number = number.abs if number.zero? # prevent showing negative zeros + number + end + + def round_significant(number) + return 0 if number.zero? + digits = digit_count(number) + multiplier = 10**(digits - precision) + (number / BigDecimal(multiplier.to_f.to_s)).round * multiplier + end + + def convert_to_decimal(number) + case number + when Float, String + BigDecimal(number.to_s) + when Rational + BigDecimal(number, digit_count(number.to_i) + precision) + else + number.to_d + end + end + + def precision + options[:precision] + end + + def significant + options[:significant] + end + + def absolute_number(number) + number.respond_to?(:abs) ? number.abs : number.to_d.abs + end + end + end +end diff --git a/activesupport/lib/active_support/option_merger.rb b/activesupport/lib/active_support/option_merger.rb new file mode 100644 index 0000000000..ab9ca727f6 --- /dev/null +++ b/activesupport/lib/active_support/option_merger.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/deep_merge" + +module ActiveSupport + class OptionMerger #:nodoc: + instance_methods.each do |method| + undef_method(method) if method !~ /^(__|instance_eval|class|object_id)/ + end + + def initialize(context, options) + @context, @options = context, options + end + + private + def method_missing(method, *arguments, &block) + if arguments.first.is_a?(Proc) + proc = arguments.pop + arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) } + else + arguments << (arguments.last.respond_to?(:to_hash) ? @options.deep_merge(arguments.pop) : @options.dup) + end + + @context.__send__(method, *arguments, &block) + end + end +end diff --git a/activesupport/lib/active_support/ordered_hash.rb b/activesupport/lib/active_support/ordered_hash.rb new file mode 100644 index 0000000000..5758513021 --- /dev/null +++ b/activesupport/lib/active_support/ordered_hash.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "yaml" + +YAML.add_builtin_type("omap") do |type, val| + ActiveSupport::OrderedHash[val.map { |v| v.to_a.first }] +end + +module ActiveSupport + # DEPRECATED: <tt>ActiveSupport::OrderedHash</tt> implements a hash that preserves + # insertion order. + # + # oh = ActiveSupport::OrderedHash.new + # oh[:a] = 1 + # oh[:b] = 2 + # oh.keys # => [:a, :b], this order is guaranteed + # + # Also, maps the +omap+ feature for YAML files + # (See http://yaml.org/type/omap.html) to support ordered items + # when loading from yaml. + # + # <tt>ActiveSupport::OrderedHash</tt> is namespaced to prevent conflicts + # with other implementations. + class OrderedHash < ::Hash + def to_yaml_type + "!tag:yaml.org,2002:omap" + end + + def encode_with(coder) + coder.represent_seq "!omap", map { |k, v| { k => v } } + end + + def select(*args, &block) + dup.tap { |hash| hash.select!(*args, &block) } + end + + def reject(*args, &block) + dup.tap { |hash| hash.reject!(*args, &block) } + end + + def nested_under_indifferent_access + self + end + + # Returns true to make sure that this hash is extractable via <tt>Array#extract_options!</tt> + def extractable_options? + true + end + end +end diff --git a/activesupport/lib/active_support/ordered_options.rb b/activesupport/lib/active_support/ordered_options.rb new file mode 100644 index 0000000000..c4e419f546 --- /dev/null +++ b/activesupport/lib/active_support/ordered_options.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/blank" + +module ActiveSupport + # Usually key value pairs are handled something like this: + # + # h = {} + # h[:boy] = 'John' + # h[:girl] = 'Mary' + # h[:boy] # => 'John' + # h[:girl] # => 'Mary' + # h[:dog] # => nil + # + # Using +OrderedOptions+, the above code could be reduced to: + # + # h = ActiveSupport::OrderedOptions.new + # h.boy = 'John' + # h.girl = 'Mary' + # h.boy # => 'John' + # h.girl # => 'Mary' + # h.dog # => nil + # + # To raise an exception when the value is blank, append a + # bang to the key name, like: + # + # h.dog! # => raises KeyError: :dog is blank + # + class OrderedOptions < Hash + alias_method :_get, :[] # preserve the original #[] method + protected :_get # make it protected + + def []=(key, value) + super(key.to_sym, value) + end + + def [](key) + super(key.to_sym) + end + + def method_missing(name, *args) + name_string = name.to_s + if name_string.chomp!("=") + self[name_string] = args.first + else + bangs = name_string.chomp!("!") + + if bangs + self[name_string].presence || raise(KeyError.new(":#{name_string} is blank")) + else + self[name_string] + end + end + end + + def respond_to_missing?(name, include_private) + true + end + end + + # +InheritableOptions+ provides a constructor to build an +OrderedOptions+ + # hash inherited from another hash. + # + # Use this if you already have some hash and you want to create a new one based on it. + # + # h = ActiveSupport::InheritableOptions.new({ girl: 'Mary', boy: 'John' }) + # h.girl # => 'Mary' + # h.boy # => 'John' + class InheritableOptions < OrderedOptions + def initialize(parent = nil) + if parent.kind_of?(OrderedOptions) + # use the faster _get when dealing with OrderedOptions + super() { |h, k| parent._get(k) } + elsif parent + super() { |h, k| parent[k] } + else + super() + end + end + + def inheritable_copy + self.class.new(self) + end + end +end diff --git a/activesupport/lib/active_support/parameter_filter.rb b/activesupport/lib/active_support/parameter_filter.rb new file mode 100644 index 0000000000..1389d82523 --- /dev/null +++ b/activesupport/lib/active_support/parameter_filter.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/duplicable" +require "active_support/core_ext/array/extract" + +module ActiveSupport + # +ParameterFilter+ allows you to specify keys for sensitive data from + # hash-like object and replace corresponding value. Filtering only certain + # sub-keys from a hash is possible by using the dot notation: + # 'credit_card.number'. If a proc is given, each key and value of a hash and + # all sub-hashes are passed to it, where the value or the key can be replaced + # using String#replace or similar methods. + # + # ActiveSupport::ParameterFilter.new([:password]) + # => replaces the value to all keys matching /password/i with "[FILTERED]" + # + # ActiveSupport::ParameterFilter.new([:foo, "bar"]) + # => replaces the value to all keys matching /foo|bar/i with "[FILTERED]" + # + # ActiveSupport::ParameterFilter.new(["credit_card.code"]) + # => replaces { credit_card: {code: "xxxx"} } with "[FILTERED]", does not + # change { file: { code: "xxxx"} } + # + # ActiveSupport::ParameterFilter.new([-> (k, v) do + # v.reverse! if k =~ /secret/i + # end]) + # => reverses the value to all keys matching /secret/i + class ParameterFilter + FILTERED = "[FILTERED]" # :nodoc: + + # Create instance with given filters. Supported type of filters are +String+, +Regexp+, and +Proc+. + # Other types of filters are treated as +String+ using +to_s+. + # For +Proc+ filters, key, value, and optional original hash is passed to block arguments. + # + # ==== Options + # + # * <tt>:mask</tt> - A replaced object when filtered. Defaults to +"[FILTERED]"+ + def initialize(filters = [], mask: FILTERED) + @filters = filters + @mask = mask + end + + # Mask value of +params+ if key matches one of filters. + def filter(params) + compiled_filter.call(params) + end + + # Returns filtered value for given key. For +Proc+ filters, third block argument is not populated. + def filter_param(key, value) + @filters.empty? ? value : compiled_filter.value_for_key(key, value) + end + + private + + def compiled_filter + @compiled_filter ||= CompiledFilter.compile(@filters, mask: @mask) + end + + class CompiledFilter # :nodoc: + def self.compile(filters, mask:) + return lambda { |params| params.dup } if filters.empty? + + strings, regexps, blocks = [], [], [] + + filters.each do |item| + case item + when Proc + blocks << item + when Regexp + regexps << item + else + strings << Regexp.escape(item.to_s) + end + end + + deep_regexps = regexps.extract! { |r| r.to_s.include?("\\.") } + deep_strings = strings.extract! { |s| s.include?("\\.") } + + regexps << Regexp.new(strings.join("|"), true) unless strings.empty? + deep_regexps << Regexp.new(deep_strings.join("|"), true) unless deep_strings.empty? + + new regexps, deep_regexps, blocks, mask: mask + end + + attr_reader :regexps, :deep_regexps, :blocks + + def initialize(regexps, deep_regexps, blocks, mask:) + @regexps = regexps + @deep_regexps = deep_regexps.any? ? deep_regexps : nil + @blocks = blocks + @mask = mask + end + + def call(params, parents = [], original_params = params) + filtered_params = params.class.new + + params.each do |key, value| + filtered_params[key] = value_for_key(key, value, parents, original_params) + end + + filtered_params + end + + def value_for_key(key, value, parents = [], original_params = nil) + parents.push(key) if deep_regexps + if regexps.any? { |r| r.match?(key) } + value = @mask + elsif deep_regexps && (joined = parents.join(".")) && deep_regexps.any? { |r| r.match?(joined) } + value = @mask + elsif value.is_a?(Hash) + value = call(value, parents, original_params) + elsif value.is_a?(Array) + value = value.map { |v| v.is_a?(Hash) ? call(v, parents, original_params) : v } + elsif blocks.any? + key = key.dup if key.duplicable? + value = value.dup if value.duplicable? + blocks.each { |b| b.arity == 2 ? b.call(key, value) : b.call(key, value, original_params) } + end + parents.pop if deep_regexps + value + end + end + end +end diff --git a/activesupport/lib/active_support/per_thread_registry.rb b/activesupport/lib/active_support/per_thread_registry.rb new file mode 100644 index 0000000000..eb92fb4371 --- /dev/null +++ b/activesupport/lib/active_support/per_thread_registry.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/delegation" + +module ActiveSupport + # NOTE: This approach has been deprecated for end-user code in favor of {thread_mattr_accessor}[rdoc-ref:Module#thread_mattr_accessor] and friends. + # Please use that approach instead. + # + # This module is used to encapsulate access to thread local variables. + # + # Instead of polluting the thread locals namespace: + # + # Thread.current[:connection_handler] + # + # you define a class that extends this module: + # + # module ActiveRecord + # class RuntimeRegistry + # extend ActiveSupport::PerThreadRegistry + # + # attr_accessor :connection_handler + # end + # end + # + # and invoke the declared instance accessors as class methods. So + # + # ActiveRecord::RuntimeRegistry.connection_handler = connection_handler + # + # sets a connection handler local to the current thread, and + # + # ActiveRecord::RuntimeRegistry.connection_handler + # + # returns a connection handler local to the current thread. + # + # This feature is accomplished by instantiating the class and storing the + # instance as a thread local keyed by the class name. In the example above + # a key "ActiveRecord::RuntimeRegistry" is stored in <tt>Thread.current</tt>. + # The class methods proxy to said thread local instance. + # + # If the class has an initializer, it must accept no arguments. + module PerThreadRegistry + def self.extended(object) + object.instance_variable_set "@per_thread_registry_key", object.name.freeze + end + + def instance + Thread.current[@per_thread_registry_key] ||= new + end + + private + 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 +end diff --git a/activesupport/lib/active_support/proxy_object.rb b/activesupport/lib/active_support/proxy_object.rb new file mode 100644 index 0000000000..0965fcd2d9 --- /dev/null +++ b/activesupport/lib/active_support/proxy_object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ActiveSupport + # A class with no predefined methods that behaves similarly to Builder's + # BlankSlate. Used for proxy classes. + class ProxyObject < ::BasicObject + undef_method :== + undef_method :equal? + + # Let ActiveSupport::ProxyObject at least raise exceptions. + def raise(*args) + ::Object.send(:raise, *args) + end + end +end diff --git a/activesupport/lib/active_support/rails.rb b/activesupport/lib/active_support/rails.rb new file mode 100644 index 0000000000..8b727a69ec --- /dev/null +++ b/activesupport/lib/active_support/rails.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# This is private interface. +# +# Rails components cherry pick from Active Support as needed, but there are a +# few features that are used for sure in some way or another and it is not worth +# putting individual requires absolutely everywhere. Think blank? for example. +# +# This file is loaded by every Rails component except Active Support itself, +# but it does not belong to the Rails public interface. It is internal to +# Rails and can change anytime. + +# Defines Object#blank? and Object#present?. +require "active_support/core_ext/object/blank" + +# Rails own autoload, eager_load, etc. +require "active_support/dependencies/autoload" + +# Support for ClassMethods and the included macro. +require "active_support/concern" + +# Defines Class#class_attribute. +require "active_support/core_ext/class/attribute" + +# Defines Module#delegate. +require "active_support/core_ext/module/delegation" + +# Defines ActiveSupport::Deprecation. +require "active_support/deprecation" diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb new file mode 100644 index 0000000000..605b50d346 --- /dev/null +++ b/activesupport/lib/active_support/railtie.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/i18n_railtie" + +module ActiveSupport + class Railtie < Rails::Railtie # :nodoc: + config.active_support = ActiveSupport::OrderedOptions.new + + config.eager_load_namespaces << ActiveSupport + + initializer "active_support.set_authenticated_message_encryption" do |app| + config.after_initialize do + unless app.config.active_support.use_authenticated_message_encryption.nil? + ActiveSupport::MessageEncryptor.use_authenticated_message_encryption = + app.config.active_support.use_authenticated_message_encryption + end + end + end + + initializer "active_support.reset_all_current_attributes_instances" do |app| + app.reloader.before_class_unload { ActiveSupport::CurrentAttributes.clear_all } + 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 + end + end + + # Sets the default value for Time.zone + # If assigned value cannot be matched to a TimeZone, an exception will be raised. + initializer "active_support.initialize_time_zone" do |app| + begin + TZInfo::DataSource.get + rescue TZInfo::DataSourceNotFound => e + raise e.exception "tzinfo-data is not present. Please add gem 'tzinfo-data' to your Gemfile and run bundle install" + end + require "active_support/core_ext/time/zones" + Time.zone_default = Time.find_zone!(app.config.time_zone) + end + + # Sets the default week start + # If assigned value is not a valid day symbol (e.g. :sunday, :monday, ...), an exception will be raised. + initializer "active_support.initialize_beginning_of_week" do |app| + require "active_support/core_ext/date/calculations" + beginning_of_week_default = Date.find_beginning_of_week!(app.config.beginning_of_week) + + Date.beginning_of_week_default = beginning_of_week_default + end + + initializer "active_support.require_master_key" do |app| + if app.config.respond_to?(:require_master_key) && app.config.require_master_key + begin + app.credentials.key + rescue ActiveSupport::EncryptedFile::MissingKeyError => error + $stderr.puts error.message + exit 1 + end + end + end + + initializer "active_support.set_configs" do |app| + app.config.active_support.each do |k, v| + k = "#{k}=" + ActiveSupport.send(k, v) if ActiveSupport.respond_to? k + end + end + + initializer "active_support.set_hash_digest_class" do |app| + config.after_initialize do + if app.config.active_support.use_sha1_digests + ActiveSupport::Digest.hash_digest_class = ::Digest::SHA1 + end + end + end + end +end diff --git a/activesupport/lib/active_support/reloader.rb b/activesupport/lib/active_support/reloader.rb new file mode 100644 index 0000000000..2f81cd4f80 --- /dev/null +++ b/activesupport/lib/active_support/reloader.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "active_support/execution_wrapper" +require "active_support/executor" + +module ActiveSupport + #-- + # This class defines several callbacks: + # + # to_prepare -- Run once at application startup, and also from + # +to_run+. + # + # to_run -- Run before a work run that is reloading. If + # +reload_classes_only_on_change+ is true (the default), the class + # unload will have already occurred. + # + # to_complete -- Run after a work run that has reloaded. If + # +reload_classes_only_on_change+ is false, the class unload will + # have occurred after the work run, but before this callback. + # + # before_class_unload -- Run immediately before the classes are + # unloaded. + # + # after_class_unload -- Run immediately after the classes are + # unloaded. + # + class Reloader < ExecutionWrapper + define_callbacks :prepare + + define_callbacks :class_unload + + # Registers a callback that will run once at application startup and every time the code is reloaded. + def self.to_prepare(*args, &block) + set_callback(:prepare, *args, &block) + end + + # Registers a callback that will run immediately before the classes are unloaded. + def self.before_class_unload(*args, &block) + set_callback(:class_unload, *args, &block) + end + + # Registers a callback that will run immediately after the classes are unloaded. + def self.after_class_unload(*args, &block) + set_callback(:class_unload, :after, *args, &block) + end + + to_run(:after) { self.class.prepare! } + + # Initiate a manual reload + def self.reload! + executor.wrap do + new.tap do |instance| + instance.run! + ensure + instance.complete! + end + end + prepare! + end + + def self.run! # :nodoc: + if check! + super + else + Null + end + end + + # Run the supplied block as a work unit, reloading code as needed + def self.wrap + executor.wrap do + super + end + end + + class_attribute :executor, default: Executor + class_attribute :check, default: lambda { false } + + def self.check! # :nodoc: + @should_reload ||= check.call + end + + def self.reloaded! # :nodoc: + @should_reload = false + end + + def self.prepare! # :nodoc: + new.run_callbacks(:prepare) + end + + def initialize + super + @locked = false + end + + # Acquire the ActiveSupport::Dependencies::Interlock unload lock, + # ensuring it will be released automatically + def require_unload_lock! + unless @locked + ActiveSupport::Dependencies.interlock.start_unloading + @locked = true + end + end + + # Release the unload lock if it has been previously obtained + def release_unload_lock! + if @locked + @locked = false + ActiveSupport::Dependencies.interlock.done_unloading + end + end + + def run! # :nodoc: + super + release_unload_lock! + end + + def class_unload!(&block) # :nodoc: + require_unload_lock! + run_callbacks(:class_unload, &block) + end + + def complete! # :nodoc: + super + self.class.reloaded! + ensure + release_unload_lock! + end + end +end diff --git a/activesupport/lib/active_support/rescuable.rb b/activesupport/lib/active_support/rescuable.rb new file mode 100644 index 0000000000..e0fa29cacb --- /dev/null +++ b/activesupport/lib/active_support/rescuable.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "active_support/core_ext/class/attribute" +require "active_support/core_ext/string/inflections" + +module ActiveSupport + # Rescuable module adds support for easier exception handling. + module Rescuable + extend Concern + + included do + class_attribute :rescue_handlers, default: [] + end + + module ClassMethods + # Rescue exceptions raised in controller actions. + # + # <tt>rescue_from</tt> receives a series of exception classes or class + # names, and a trailing <tt>:with</tt> option with the name of a method + # or a Proc object to be called to handle them. Alternatively a block can + # be given. + # + # Handlers that take one argument will be called with the exception, so + # that the exception can be inspected when dealing with it. + # + # Handlers are inherited. They are searched from right to left, from + # bottom to top, and up the hierarchy. The handler of the first class for + # which <tt>exception.is_a?(klass)</tt> holds true is the one invoked, if + # any. + # + # class ApplicationController < ActionController::Base + # rescue_from User::NotAuthorized, with: :deny_access # self defined exception + # rescue_from ActiveRecord::RecordInvalid, with: :show_errors + # + # rescue_from 'MyAppError::Base' do |exception| + # render xml: exception, status: 500 + # end + # + # private + # def deny_access + # ... + # end + # + # def show_errors(exception) + # exception.record.new_record? ? ... + # end + # end + # + # Exceptions raised inside exception handlers are not propagated up. + def rescue_from(*klasses, with: nil, &block) + unless with + if block_given? + with = block + else + raise ArgumentError, "Need a handler. Pass the with: keyword argument or provide a block." + end + end + + klasses.each do |klass| + key = if klass.is_a?(Module) && klass.respond_to?(:===) + klass.name + elsif klass.is_a?(String) + klass + else + raise ArgumentError, "#{klass.inspect} must be an Exception class or a String referencing an Exception class" + end + + # Put the new handler at the end because the list is read in reverse. + self.rescue_handlers += [[key, with]] + end + end + + # Matches an exception to a handler based on the exception class. + # + # If no handler matches the exception, check for a handler matching the + # (optional) exception.cause. If no handler matches the exception or its + # cause, this returns +nil+, so you can deal with unhandled exceptions. + # Be sure to re-raise unhandled exceptions if this is what you expect. + # + # begin + # … + # rescue => exception + # rescue_with_handler(exception) || raise + # end + # + # Returns the exception if it was handled and +nil+ if it was not. + def rescue_with_handler(exception, object: self, visited_exceptions: []) + visited_exceptions << exception + + if handler = handler_for_rescue(exception, object: object) + handler.call exception + exception + elsif exception + if visited_exceptions.include?(exception.cause) + nil + else + rescue_with_handler(exception.cause, object: object, visited_exceptions: visited_exceptions) + end + end + end + + def handler_for_rescue(exception, object: self) #:nodoc: + case rescuer = find_rescue_handler(exception) + when Symbol + method = object.method(rescuer) + if method.arity == 0 + -> e { method.call } + else + method + end + when Proc + if rescuer.arity == 0 + -> e { object.instance_exec(&rescuer) } + else + -> e { object.instance_exec(e, &rescuer) } + end + end + end + + private + def find_rescue_handler(exception) + if exception + # Handlers are in order of declaration but the most recently declared + # is the highest priority match, so we search for matching handlers + # in reverse. + _, handler = rescue_handlers.reverse_each.detect do |class_or_name, _| + if klass = constantize_rescue_handler_class(class_or_name) + klass === exception + end + end + + handler + end + end + + def constantize_rescue_handler_class(class_or_name) + case class_or_name + when String, Symbol + begin + # Try a lexical lookup first since we support + # + # class Super + # rescue_from 'Error', with: … + # end + # + # class Sub + # class Error < StandardError; end + # end + # + # so an Error raised in Sub will hit the 'Error' handler. + const_get class_or_name + rescue NameError + class_or_name.safe_constantize + end + else + class_or_name + end + end + end + + # Delegates to the class method, but uses the instance as the subject for + # rescue_from handlers (method calls, instance_exec blocks). + def rescue_with_handler(exception) + self.class.rescue_with_handler exception, object: self + end + + # Internal handler lookup. Delegates to class method. Some libraries call + # this directly, so keeping it around for compatibility. + def handler_for_rescue(exception) #:nodoc: + self.class.handler_for_rescue exception, object: self + end + end +end diff --git a/activesupport/lib/active_support/security_utils.rb b/activesupport/lib/active_support/security_utils.rb new file mode 100644 index 0000000000..20b6b9cd3f --- /dev/null +++ b/activesupport/lib/active_support/security_utils.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "digest/sha2" + +module ActiveSupport + module SecurityUtils + # Constant time string comparison, for fixed length strings. + # + # The values compared should be of fixed length, such as strings + # that have already been processed by HMAC. Raises in case of length mismatch. + def fixed_length_secure_compare(a, b) + raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize + + l = a.unpack "C#{a.bytesize}" + + res = 0 + b.each_byte { |byte| res |= byte ^ l.shift } + res == 0 + end + module_function :fixed_length_secure_compare + + # Constant time string comparison, for variable length strings. + # + # The values are first processed by SHA256, so that we don't leak length info + # via timing attacks. + def secure_compare(a, b) + fixed_length_secure_compare(::Digest::SHA256.hexdigest(a), ::Digest::SHA256.hexdigest(b)) && a == b + end + module_function :secure_compare + end +end diff --git a/activesupport/lib/active_support/string_inquirer.rb b/activesupport/lib/active_support/string_inquirer.rb new file mode 100644 index 0000000000..a3af36720e --- /dev/null +++ b/activesupport/lib/active_support/string_inquirer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActiveSupport + # Wrapping a string in this class gives you a prettier way to test + # for equality. The value returned by <tt>Rails.env</tt> is wrapped + # in a StringInquirer object, so instead of calling this: + # + # Rails.env == 'production' + # + # you can call this: + # + # Rails.env.production? + # + # == Instantiating a new StringInquirer + # + # vehicle = ActiveSupport::StringInquirer.new('car') + # vehicle.car? # => true + # vehicle.bike? # => false + class StringInquirer < String + private + + def respond_to_missing?(method_name, include_private = false) + (method_name[-1] == "?") || super + end + + def method_missing(method_name, *arguments) + if method_name[-1] == "?" + self == method_name[0..-2] + else + super + end + end + end +end diff --git a/activesupport/lib/active_support/subscriber.rb b/activesupport/lib/active_support/subscriber.rb new file mode 100644 index 0000000000..f3e902f9dd --- /dev/null +++ b/activesupport/lib/active_support/subscriber.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "active_support/per_thread_registry" +require "active_support/notifications" + +module ActiveSupport + # ActiveSupport::Subscriber is an object set to consume + # ActiveSupport::Notifications. The subscriber dispatches notifications to + # a registered object based on its given namespace. + # + # An example would be an Active Record subscriber responsible for collecting + # statistics about queries: + # + # module ActiveRecord + # class StatsSubscriber < ActiveSupport::Subscriber + # attach_to :active_record + # + # def sql(event) + # Statsd.timing("sql.#{event.payload[:name]}", event.duration) + # end + # end + # end + # + # After configured, whenever a "sql.active_record" notification is published, + # it will properly dispatch the event (ActiveSupport::Notifications::Event) to + # the +sql+ method. + class Subscriber + class << self + # Attach the subscriber to a namespace. + def attach_to(namespace, subscriber = new, notifier = ActiveSupport::Notifications) + @namespace = namespace + @subscriber = subscriber + @notifier = notifier + + subscribers << subscriber + + # Add event subscribers for all existing methods on the class. + subscriber.public_methods(false).each do |event| + add_event_subscriber(event) + end + end + + # Adds event subscribers for all new methods added to the class. + def method_added(event) + # Only public methods are added as subscribers, and only if a notifier + # has been set up. This means that subscribers will only be set up for + # classes that call #attach_to. + if public_method_defined?(event) && notifier + add_event_subscriber(event) + end + end + + def subscribers + @@subscribers ||= [] + end + + private + attr_reader :subscriber, :notifier, :namespace + + def add_event_subscriber(event) # :doc: + return if %w{ start finish }.include?(event.to_s) + + pattern = "#{event}.#{namespace}" + + # Don't add multiple subscribers (eg. if methods are redefined). + return if subscriber.patterns.include?(pattern) + + subscriber.patterns << pattern + notifier.subscribe(pattern, subscriber) + end + end + + attr_reader :patterns # :nodoc: + + def initialize + @queue_key = [self.class.name, object_id].join "-" + @patterns = [] + super + end + + def start(name, id, payload) + event = ActiveSupport::Notifications::Event.new(name, nil, nil, id, payload) + event.start! + parent = event_stack.last + parent << event if parent + + event_stack.push event + end + + def finish(name, id, payload) + event = event_stack.pop + event.finish! + event.payload.merge!(payload) + + method = name.split(".").first + send(method, event) + end + + private + def event_stack + SubscriberQueueRegistry.instance.get_queue(@queue_key) + end + end + + # This is a registry for all the event stacks kept for subscribers. + # + # See the documentation of <tt>ActiveSupport::PerThreadRegistry</tt> + # for further details. + class SubscriberQueueRegistry # :nodoc: + extend PerThreadRegistry + + def initialize + @registry = {} + end + + def get_queue(queue_key) + @registry[queue_key] ||= [] + end + end +end diff --git a/activesupport/lib/active_support/tagged_logging.rb b/activesupport/lib/active_support/tagged_logging.rb new file mode 100644 index 0000000000..d8a86d997e --- /dev/null +++ b/activesupport/lib/active_support/tagged_logging.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/delegation" +require "active_support/core_ext/object/blank" +require "logger" +require "active_support/logger" + +module ActiveSupport + # Wraps any standard Logger object to provide tagging capabilities. + # + # logger = ActiveSupport::TaggedLogging.new(Logger.new(STDOUT)) + # logger.tagged('BCX') { logger.info 'Stuff' } # Logs "[BCX] Stuff" + # logger.tagged('BCX', "Jason") { logger.info 'Stuff' } # Logs "[BCX] [Jason] Stuff" + # logger.tagged('BCX') { logger.tagged('Jason') { logger.info 'Stuff' } } # Logs "[BCX] [Jason] Stuff" + # + # This is used by the default Rails.logger as configured by Railties to make + # it easy to stamp log lines with subdomains, request ids, and anything else + # to aid debugging of multi-user production applications. + module TaggedLogging + module Formatter # :nodoc: + # This method is invoked when a log event occurs. + def call(severity, timestamp, progname, msg) + super(severity, timestamp, progname, "#{tags_text}#{msg}") + end + + def tagged(*tags) + new_tags = push_tags(*tags) + yield self + ensure + pop_tags(new_tags.size) + end + + def push_tags(*tags) + tags.flatten.reject(&:blank?).tap do |new_tags| + current_tags.concat new_tags + end + end + + def pop_tags(size = 1) + current_tags.pop size + end + + def clear_tags! + current_tags.clear + end + + def current_tags + # We use our object ID here to avoid conflicting with other instances + thread_key = @thread_key ||= "activesupport_tagged_logging_tags:#{object_id}" + Thread.current[thread_key] ||= [] + end + + def tags_text + tags = current_tags + if tags.one? + "[#{tags[0]}] " + elsif tags.any? + tags.collect { |tag| "[#{tag}] " }.join + end + end + end + + def self.new(logger) + logger = logger.dup + + if logger.formatter + logger.formatter = logger.formatter.dup + else + # Ensure we set a default formatter so we aren't extending nil! + logger.formatter = ActiveSupport::Logger::SimpleFormatter.new + end + + logger.formatter.extend Formatter + logger.extend(self) + end + + delegate :push_tags, :pop_tags, :clear_tags!, to: :formatter + + def tagged(*tags) + formatter.tagged(*tags) { yield self } + end + + def flush + clear_tags! + super if defined?(super) + end + end +end diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb new file mode 100644 index 0000000000..7be4108ed7 --- /dev/null +++ b/activesupport/lib/active_support/test_case.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +gem "minitest" # make sure we get the gem, not stdlib +require "minitest" +require "active_support/testing/tagged_logging" +require "active_support/testing/setup_and_teardown" +require "active_support/testing/assertions" +require "active_support/testing/deprecation" +require "active_support/testing/declarative" +require "active_support/testing/isolation" +require "active_support/testing/constant_lookup" +require "active_support/testing/time_helpers" +require "active_support/testing/file_fixtures" +require "active_support/testing/parallelization" +require "concurrent/utility/processor_counter" + +module ActiveSupport + class TestCase < ::Minitest::Test + Assertion = Minitest::Assertion + + class << self + # Sets the order in which test cases are run. + # + # ActiveSupport::TestCase.test_order = :random # => :random + # + # Valid values are: + # * +:random+ (to run tests in random order) + # * +:parallel+ (to run tests in parallel) + # * +:sorted+ (to run tests alphabetically by method name) + # * +:alpha+ (equivalent to +:sorted+) + def test_order=(new_order) + ActiveSupport.test_order = new_order + end + + # Returns the order in which test cases are run. + # + # ActiveSupport::TestCase.test_order # => :random + # + # Possible values are +:random+, +:parallel+, +:alpha+, +:sorted+. + # Defaults to +:random+. + def test_order + ActiveSupport.test_order ||= :random + end + + # Parallelizes the test suite. + # + # Takes a +workers+ argument that controls how many times the process + # is forked. For each process a new database will be created suffixed + # with the worker number. + # + # test-database-0 + # test-database-1 + # + # If <tt>ENV["PARALLEL_WORKERS"]</tt> is set the workers argument will be ignored + # and the environment variable will be used instead. This is useful for CI + # environments, or other environments where you may need more workers than + # you do for local testing. + # + # If the number of workers is set to +1+ or fewer, the tests will not be + # parallelized. + # + # If +workers+ is set to +:number_of_processors+, the number of workers will be + # set to the actual core count on the machine you are on. + # + # The default parallelization method is to fork processes. If you'd like to + # use threads instead you can pass <tt>with: :threads</tt> to the +parallelize+ + # method. Note the threaded parallelization does not create multiple + # database and will not work with system tests at this time. + # + # parallelize(workers: :number_of_processors, with: :threads) + # + # The threaded parallelization uses minitest's parallel executor directly. + # The processes parallelization uses a Ruby DRb server. + def parallelize(workers: :number_of_processors, with: :processes) + workers = Concurrent.physical_processor_count if workers == :number_of_processors + workers = ENV["PARALLEL_WORKERS"].to_i if ENV["PARALLEL_WORKERS"] + + return if workers <= 1 + + executor = case with + when :processes + Testing::Parallelization.new(workers) + when :threads + Minitest::Parallel::Executor.new(workers) + else + raise ArgumentError, "#{with} is not a supported parallelization executor." + end + + self.lock_threads = false if defined?(self.lock_threads) && with == :threads + + Minitest.parallel_executor = executor + + parallelize_me! + end + + # Set up hook for parallel testing. This can be used if you have multiple + # databases or any behavior that needs to be run after the process is forked + # but before the tests run. + # + # Note: this feature is not available with the threaded parallelization. + # + # In your +test_helper.rb+ add the following: + # + # class ActiveSupport::TestCase + # parallelize_setup do + # # create databases + # end + # end + def parallelize_setup(&block) + ActiveSupport::Testing::Parallelization.after_fork_hook do |worker| + yield worker + end + end + + # Clean up hook for parallel testing. This can be used to drop databases + # if your app uses multiple write/read databases or other clean up before + # the tests finish. This runs before the forked process is closed. + # + # Note: this feature is not available with the threaded parallelization. + # + # In your +test_helper.rb+ add the following: + # + # class ActiveSupport::TestCase + # parallelize_teardown do + # # drop databases + # end + # end + def parallelize_teardown(&block) + ActiveSupport::Testing::Parallelization.run_cleanup_hook do |worker| + yield worker + end + end + end + + alias_method :method_name, :name + + include ActiveSupport::Testing::TaggedLogging + prepend ActiveSupport::Testing::SetupAndTeardown + include ActiveSupport::Testing::Assertions + include ActiveSupport::Testing::Deprecation + include ActiveSupport::Testing::TimeHelpers + include ActiveSupport::Testing::FileFixtures + extend ActiveSupport::Testing::Declarative + + # test/unit backwards compatibility methods + alias :assert_raise :assert_raises + alias :assert_not_empty :refute_empty + alias :assert_not_equal :refute_equal + alias :assert_not_in_delta :refute_in_delta + alias :assert_not_in_epsilon :refute_in_epsilon + alias :assert_not_includes :refute_includes + alias :assert_not_instance_of :refute_instance_of + alias :assert_not_kind_of :refute_kind_of + alias :assert_no_match :refute_match + alias :assert_not_nil :refute_nil + alias :assert_not_operator :refute_operator + alias :assert_not_predicate :refute_predicate + alias :assert_not_respond_to :refute_respond_to + alias :assert_not_same :refute_same + + ActiveSupport.run_load_hooks(:active_support_test_case, self) + end +end diff --git a/activesupport/lib/active_support/testing/assertions.rb b/activesupport/lib/active_support/testing/assertions.rb new file mode 100644 index 0000000000..b27ac7ce99 --- /dev/null +++ b/activesupport/lib/active_support/testing/assertions.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module ActiveSupport + module Testing + module Assertions + UNTRACKED = Object.new # :nodoc: + + # Asserts that an expression is not truthy. Passes if <tt>object</tt> is + # +nil+ or +false+. "Truthy" means "considered true in a conditional" + # like <tt>if foo</tt>. + # + # assert_not nil # => true + # assert_not false # => true + # assert_not 'foo' # => Expected "foo" to be nil or false + # + # An error message can be specified. + # + # assert_not foo, 'foo should be false' + def assert_not(object, message = nil) + message ||= "Expected #{mu_pp(object)} to be nil or false" + assert !object, message + end + + # Assertion that the block should not raise an exception. + # + # Passes if evaluated code in the yielded block raises no exception. + # + # assert_nothing_raised do + # perform_service(param: 'no_exception') + # end + def assert_nothing_raised + yield + end + + # Test numeric difference between the return value of an expression as a + # result of what is evaluated in the yielded block. + # + # assert_difference 'Article.count' do + # post :create, params: { article: {...} } + # end + # + # An arbitrary expression is passed in and evaluated. + # + # assert_difference 'Article.last.comments(:reload).size' do + # post :create, params: { comment: {...} } + # end + # + # An arbitrary positive or negative difference can be specified. + # The default is <tt>1</tt>. + # + # assert_difference 'Article.count', -1 do + # post :delete, params: { id: ... } + # end + # + # An array of expressions can also be passed in and evaluated. + # + # assert_difference [ 'Article.count', 'Post.count' ], 2 do + # post :create, params: { article: {...} } + # end + # + # A hash of expressions/numeric differences can also be passed in and evaluated. + # + # assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do + # post :create, params: { article: {...} } + # end + # + # A lambda or a list of lambdas can be passed in and evaluated: + # + # assert_difference ->{ Article.count }, 2 do + # post :create, params: { article: {...} } + # end + # + # assert_difference [->{ Article.count }, ->{ Post.count }], 2 do + # post :create, params: { article: {...} } + # end + # + # An error message can be specified. + # + # assert_difference 'Article.count', -1, 'An Article should be destroyed' do + # post :delete, params: { id: ... } + # end + def assert_difference(expression, *args, &block) + expressions = + if expression.is_a?(Hash) + message = args[0] + expression + else + difference = args[0] || 1 + message = args[1] + Hash[Array(expression).map { |e| [e, difference] }] + end + + exps = expressions.keys.map { |e| + e.respond_to?(:call) ? e : lambda { eval(e, block.binding) } + } + before = exps.map(&:call) + + retval = yield + + expressions.zip(exps, before) do |(code, diff), exp, before_value| + error = "#{code.inspect} didn't change by #{diff}" + error = "#{message}.\n#{error}" if message + assert_equal(before_value + diff, exp.call, error) + end + + retval + end + + # Assertion that the numeric result of evaluating an expression is not + # changed before and after invoking the passed in block. + # + # assert_no_difference 'Article.count' do + # post :create, params: { article: invalid_attributes } + # end + # + # A lambda can be passed in and evaluated. + # + # assert_no_difference -> { Article.count } do + # post :create, params: { article: invalid_attributes } + # end + # + # An error message can be specified. + # + # assert_no_difference 'Article.count', 'An Article should not be created' do + # post :create, params: { article: invalid_attributes } + # end + # + # An array of expressions can also be passed in and evaluated. + # + # assert_no_difference [ 'Article.count', -> { Post.count } ] do + # post :create, params: { article: invalid_attributes } + # end + def assert_no_difference(expression, message = nil, &block) + assert_difference expression, 0, message, &block + end + + # Assertion that the result of evaluating an expression is changed before + # and after invoking the passed in block. + # + # assert_changes 'Status.all_good?' do + # post :create, params: { status: { ok: false } } + # end + # + # You can pass the block as a string to be evaluated in the context of + # the block. A lambda can be passed for the block as well. + # + # assert_changes -> { Status.all_good? } do + # post :create, params: { status: { ok: false } } + # end + # + # The assertion is useful to test side effects. The passed block can be + # anything that can be converted to string with #to_s. + # + # assert_changes :@object do + # @object = 42 + # end + # + # The keyword arguments :from and :to can be given to specify the + # expected initial value and the expected value after the block was + # executed. + # + # assert_changes :@object, from: nil, to: :foo do + # @object = :foo + # end + # + # An error message can be specified. + # + # assert_changes -> { Status.all_good? }, 'Expected the status to be bad' do + # post :create, params: { status: { incident: true } } + # end + def assert_changes(expression, message = nil, from: UNTRACKED, to: UNTRACKED, &block) + exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) } + + before = exp.call + retval = yield + + unless from == UNTRACKED + error = "#{expression.inspect} isn't #{from.inspect}" + error = "#{message}.\n#{error}" if message + assert from === before, error + end + + after = exp.call + + error = "#{expression.inspect} didn't change" + error = "#{error}. It was already #{to}" if before == to + error = "#{message}.\n#{error}" if message + assert before != after, error + + unless to == UNTRACKED + error = "#{expression.inspect} didn't change to as expected\n" + error = "#{error}Expected: #{to.inspect}\n" + error = "#{error} Actual: #{after.inspect}" + error = "#{message}.\n#{error}" if message + assert to === after, error + end + + retval + end + + # Assertion that the result of evaluating an expression is not changed before + # and after invoking the passed in block. + # + # assert_no_changes 'Status.all_good?' do + # post :create, params: { status: { ok: true } } + # end + # + # An error message can be specified. + # + # assert_no_changes -> { Status.all_good? }, 'Expected the status to be good' do + # post :create, params: { status: { ok: false } } + # end + def assert_no_changes(expression, message = nil, &block) + exp = expression.respond_to?(:call) ? expression : -> { eval(expression.to_s, block.binding) } + + before = exp.call + retval = yield + after = exp.call + + error = "#{expression.inspect} did change to #{after}" + error = "#{message}.\n#{error}" if message + assert before == after, error + + retval + end + end + end +end diff --git a/activesupport/lib/active_support/testing/autorun.rb b/activesupport/lib/active_support/testing/autorun.rb new file mode 100644 index 0000000000..889b41659a --- /dev/null +++ b/activesupport/lib/active_support/testing/autorun.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +gem "minitest" + +require "minitest" + +Minitest.autorun diff --git a/activesupport/lib/active_support/testing/constant_lookup.rb b/activesupport/lib/active_support/testing/constant_lookup.rb new file mode 100644 index 0000000000..51167e9237 --- /dev/null +++ b/activesupport/lib/active_support/testing/constant_lookup.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "active_support/inflector" + +module ActiveSupport + module Testing + # Resolves a constant from a minitest spec name. + # + # Given the following spec-style test: + # + # describe WidgetsController, :index do + # describe "authenticated user" do + # describe "returns widgets" do + # it "has a controller that exists" do + # assert_kind_of WidgetsController, @controller + # end + # end + # end + # end + # + # The test will have the following name: + # + # "WidgetsController::index::authenticated user::returns widgets" + # + # The constant WidgetsController can be resolved from the name. + # The following code will resolve the constant: + # + # controller = determine_constant_from_test_name(name) do |constant| + # Class === constant && constant < ::ActionController::Metal + # end + module ConstantLookup + extend ::ActiveSupport::Concern + + module ClassMethods # :nodoc: + def determine_constant_from_test_name(test_name) + names = test_name.split "::" + while names.size > 0 do + names.last.sub!(/Test$/, "") + begin + constant = names.join("::").safe_constantize + break(constant) if yield(constant) + ensure + names.pop + end + end + end + end + end + end +end diff --git a/activesupport/lib/active_support/testing/declarative.rb b/activesupport/lib/active_support/testing/declarative.rb new file mode 100644 index 0000000000..7c3403684d --- /dev/null +++ b/activesupport/lib/active_support/testing/declarative.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ActiveSupport + module Testing + module Declarative + unless defined?(Spec) + # Helper to define a test method using a String. Under the hood, it replaces + # spaces with underscores and defines the test method. + # + # test "verify something" do + # ... + # end + def test(name, &block) + test_name = "test_#{name.gsub(/\s+/, '_')}".to_sym + defined = method_defined? test_name + raise "#{test_name} is already defined in #{self}" if defined + if block_given? + define_method(test_name, &block) + else + define_method(test_name) do + flunk "No implementation provided for #{name}" + end + end + end + end + end + end +end diff --git a/activesupport/lib/active_support/testing/deprecation.rb b/activesupport/lib/active_support/testing/deprecation.rb new file mode 100644 index 0000000000..18d63d2780 --- /dev/null +++ b/activesupport/lib/active_support/testing/deprecation.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "active_support/deprecation" + +module ActiveSupport + module Testing + module Deprecation #:nodoc: + def assert_deprecated(match = nil, deprecator = nil, &block) + result, warnings = collect_deprecations(deprecator, &block) + assert !warnings.empty?, "Expected a deprecation warning within the block but received none" + if match + match = Regexp.new(Regexp.escape(match)) unless match.is_a?(Regexp) + assert warnings.any? { |w| match.match?(w) }, "No deprecation warning matched #{match}: #{warnings.join(', ')}" + end + result + end + + def assert_not_deprecated(deprecator = nil, &block) + result, deprecations = collect_deprecations(deprecator, &block) + assert deprecations.empty?, "Expected no deprecation warning within the block but received #{deprecations.size}: \n #{deprecations * "\n "}" + result + end + + def collect_deprecations(deprecator = nil) + deprecator ||= ActiveSupport::Deprecation + old_behavior = deprecator.behavior + deprecations = [] + deprecator.behavior = Proc.new do |message, callstack| + deprecations << message + end + result = yield + [result, deprecations] + ensure + deprecator.behavior = old_behavior + end + end + end +end diff --git a/activesupport/lib/active_support/testing/file_fixtures.rb b/activesupport/lib/active_support/testing/file_fixtures.rb new file mode 100644 index 0000000000..4eb7a88576 --- /dev/null +++ b/activesupport/lib/active_support/testing/file_fixtures.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module ActiveSupport + module Testing + # Adds simple access to sample files called file fixtures. + # File fixtures are normal files stored in + # <tt>ActiveSupport::TestCase.file_fixture_path</tt>. + # + # File fixtures are represented as +Pathname+ objects. + # This makes it easy to extract specific information: + # + # file_fixture("example.txt").read # get the file's content + # file_fixture("example.mp3").size # get the file size + module FileFixtures + extend ActiveSupport::Concern + + included do + class_attribute :file_fixture_path, instance_writer: false + end + + # Returns a +Pathname+ to the fixture file named +fixture_name+. + # + # Raises +ArgumentError+ if +fixture_name+ can't be found. + def file_fixture(fixture_name) + path = Pathname.new(File.join(file_fixture_path, fixture_name)) + + if path.exist? + path + else + msg = "the directory '%s' does not contain a file named '%s'" + raise ArgumentError, msg % [file_fixture_path, fixture_name] + end + end + end + end +end diff --git a/activesupport/lib/active_support/testing/isolation.rb b/activesupport/lib/active_support/testing/isolation.rb new file mode 100644 index 0000000000..652a10da23 --- /dev/null +++ b/activesupport/lib/active_support/testing/isolation.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module ActiveSupport + module Testing + module Isolation + require "thread" + + def self.included(klass) #:nodoc: + klass.class_eval do + parallelize_me! + end + end + + def self.forking_env? + !ENV["NO_FORK"] && Process.respond_to?(:fork) + end + + def run + serialized = run_in_isolation do + super + end + + Marshal.load(serialized) + end + + module Forking + def run_in_isolation(&blk) + read, write = IO.pipe + read.binmode + write.binmode + + pid = fork do + read.close + yield + begin + if error? + failures.map! { |e| + begin + Marshal.dump e + e + rescue TypeError + ex = Exception.new e.message + ex.set_backtrace e.backtrace + Minitest::UnexpectedError.new ex + end + } + end + test_result = defined?(Minitest::Result) ? Minitest::Result.from(self) : dup + result = Marshal.dump(test_result) + end + + write.puts [result].pack("m") + exit! + end + + write.close + result = read.read + Process.wait2(pid) + result.unpack1("m") + end + end + + module Subprocess + ORIG_ARGV = ARGV.dup unless defined?(ORIG_ARGV) + + # Crazy H4X to get this working in windows / jruby with + # no forking. + def run_in_isolation(&blk) + require "tempfile" + + if ENV["ISOLATION_TEST"] + yield + test_result = defined?(Minitest::Result) ? Minitest::Result.from(self) : dup + File.open(ENV["ISOLATION_OUTPUT"], "w") do |file| + file.puts [Marshal.dump(test_result)].pack("m") + end + exit! + else + Tempfile.open("isolation") do |tmpfile| + env = { + "ISOLATION_TEST" => self.class.name, + "ISOLATION_OUTPUT" => tmpfile.path + } + + test_opts = "-n#{self.class.name}##{name}" + + load_path_args = [] + $-I.each do |p| + load_path_args << "-I" + load_path_args << File.expand_path(p) + end + + child = IO.popen([env, Gem.ruby, *load_path_args, $0, *ORIG_ARGV, test_opts]) + + begin + Process.wait(child.pid) + rescue Errno::ECHILD # The child process may exit before we wait + nil + end + + return tmpfile.read.unpack1("m") + end + end + end + end + + include forking_env? ? Forking : Subprocess + end + end +end diff --git a/activesupport/lib/active_support/testing/method_call_assertions.rb b/activesupport/lib/active_support/testing/method_call_assertions.rb new file mode 100644 index 0000000000..03c38be481 --- /dev/null +++ b/activesupport/lib/active_support/testing/method_call_assertions.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "minitest/mock" + +module ActiveSupport + module Testing + module MethodCallAssertions # :nodoc: + private + def assert_called(object, method_name, message = nil, times: 1, returns: nil) + times_called = 0 + + object.stub(method_name, proc { times_called += 1; returns }) { yield } + + error = "Expected #{method_name} to be called #{times} times, " \ + "but was called #{times_called} times" + error = "#{message}.\n#{error}" if message + assert_equal times, times_called, error + end + + def assert_called_with(object, method_name, args, returns: nil) + mock = Minitest::Mock.new + + if args.all? { |arg| arg.is_a?(Array) } + args.each { |arg| mock.expect(:call, returns, arg) } + else + mock.expect(:call, returns, args) + end + + object.stub(method_name, mock) { yield } + + mock.verify + end + + def assert_not_called(object, method_name, message = nil, &block) + assert_called(object, method_name, message, times: 0, &block) + end + + def assert_called_on_instance_of(klass, method_name, message = nil, times: 1, returns: nil) + times_called = 0 + klass.define_method("stubbed_#{method_name}") do |*| + times_called += 1 + + returns + end + + klass.alias_method "original_#{method_name}", method_name + klass.alias_method method_name, "stubbed_#{method_name}" + + yield + + error = "Expected #{method_name} to be called #{times} times, but was called #{times_called} times" + error = "#{message}.\n#{error}" if message + + assert_equal times, times_called, error + ensure + klass.alias_method method_name, "original_#{method_name}" + klass.undef_method "original_#{method_name}" + klass.undef_method "stubbed_#{method_name}" + end + + def assert_not_called_on_instance_of(klass, method_name, message = nil, &block) + assert_called_on_instance_of(klass, method_name, message, times: 0, &block) + end + + def stub_any_instance(klass, instance: klass.new) + klass.stub(:new, instance) { yield instance } + end + end + end +end diff --git a/activesupport/lib/active_support/testing/parallelization.rb b/activesupport/lib/active_support/testing/parallelization.rb new file mode 100644 index 0000000000..63440069b1 --- /dev/null +++ b/activesupport/lib/active_support/testing/parallelization.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "drb" +require "drb/unix" unless Gem.win_platform? +require "active_support/core_ext/module/attribute_accessors" + +module ActiveSupport + module Testing + class Parallelization # :nodoc: + class Server + include DRb::DRbUndumped + + def initialize + @queue = Queue.new + end + + def record(reporter, result) + raise DRb::DRbConnError if result.is_a?(DRb::DRbUnknown) + + reporter.synchronize do + reporter.record(result) + end + end + + def <<(o) + o[2] = DRbObject.new(o[2]) if o + @queue << o + end + + def pop; @queue.pop; end + end + + @@after_fork_hooks = [] + + def self.after_fork_hook(&blk) + @@after_fork_hooks << blk + end + + cattr_reader :after_fork_hooks + + @@run_cleanup_hooks = [] + + def self.run_cleanup_hook(&blk) + @@run_cleanup_hooks << blk + end + + cattr_reader :run_cleanup_hooks + + def initialize(queue_size) + @queue_size = queue_size + @queue = Server.new + @pool = [] + + @url = DRb.start_service("drbunix:", @queue).uri + end + + def after_fork(worker) + self.class.after_fork_hooks.each do |cb| + cb.call(worker) + end + end + + def run_cleanup(worker) + self.class.run_cleanup_hooks.each do |cb| + cb.call(worker) + end + end + + def start + @pool = @queue_size.times.map do |worker| + fork do + DRb.stop_service + + after_fork(worker) + + queue = DRbObject.new_with_uri(@url) + + while job = queue.pop + klass = job[0] + method = job[1] + reporter = job[2] + result = Minitest.run_one_method(klass, method) + + begin + queue.record(reporter, result) + rescue DRb::DRbConnError + result.failures.each do |failure| + failure.exception = DRb::DRbRemoteError.new(failure.exception) + end + queue.record(reporter, result) + end + end + ensure + run_cleanup(worker) + end + end + end + + def <<(work) + @queue << work + end + + def shutdown + @queue_size.times { @queue << nil } + @pool.each { |pid| Process.waitpid pid } + end + end + end +end diff --git a/activesupport/lib/active_support/testing/setup_and_teardown.rb b/activesupport/lib/active_support/testing/setup_and_teardown.rb new file mode 100644 index 0000000000..35321cd157 --- /dev/null +++ b/activesupport/lib/active_support/testing/setup_and_teardown.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "active_support/callbacks" + +module ActiveSupport + module Testing + # Adds support for +setup+ and +teardown+ callbacks. + # These callbacks serve as a replacement to overwriting the + # <tt>#setup</tt> and <tt>#teardown</tt> methods of your TestCase. + # + # class ExampleTest < ActiveSupport::TestCase + # setup do + # # ... + # end + # + # teardown do + # # ... + # end + # end + module SetupAndTeardown + def self.prepended(klass) + klass.include ActiveSupport::Callbacks + klass.define_callbacks :setup, :teardown + klass.extend ClassMethods + end + + module ClassMethods + # Add a callback, which runs before <tt>TestCase#setup</tt>. + def setup(*args, &block) + set_callback(:setup, :before, *args, &block) + end + + # Add a callback, which runs after <tt>TestCase#teardown</tt>. + def teardown(*args, &block) + set_callback(:teardown, :after, *args, &block) + end + end + + def before_setup # :nodoc: + super + run_callbacks :setup + end + + def after_teardown # :nodoc: + begin + run_callbacks :teardown + rescue => e + self.failures << Minitest::UnexpectedError.new(e) + end + + super + end + end + end +end diff --git a/activesupport/lib/active_support/testing/stream.rb b/activesupport/lib/active_support/testing/stream.rb new file mode 100644 index 0000000000..127cfe1e12 --- /dev/null +++ b/activesupport/lib/active_support/testing/stream.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ActiveSupport + module Testing + module Stream #:nodoc: + private + + def silence_stream(stream) + old_stream = stream.dup + stream.reopen(IO::NULL) + stream.sync = true + yield + ensure + stream.reopen(old_stream) + old_stream.close + end + + def quietly + silence_stream(STDOUT) do + silence_stream(STDERR) do + yield + end + end + end + + def capture(stream) + stream = stream.to_s + captured_stream = Tempfile.new(stream) + stream_io = eval("$#{stream}") + origin_stream = stream_io.dup + stream_io.reopen(captured_stream) + + yield + + stream_io.rewind + captured_stream.read + ensure + captured_stream.close + captured_stream.unlink + stream_io.reopen(origin_stream) + end + end + end +end diff --git a/activesupport/lib/active_support/testing/tagged_logging.rb b/activesupport/lib/active_support/testing/tagged_logging.rb new file mode 100644 index 0000000000..9ca50c7918 --- /dev/null +++ b/activesupport/lib/active_support/testing/tagged_logging.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ActiveSupport + module Testing + # Logs a "PostsControllerTest: test name" heading before each test to + # make test.log easier to search and follow along with. + module TaggedLogging #:nodoc: + attr_writer :tagged_logger + + def before_setup + if tagged_logger && tagged_logger.info? + heading = "#{self.class}: #{name}" + divider = "-" * heading.size + tagged_logger.info divider + tagged_logger.info heading + tagged_logger.info divider + end + super + end + + private + def tagged_logger + @tagged_logger ||= (defined?(Rails.logger) && Rails.logger) + end + end + end +end diff --git a/activesupport/lib/active_support/testing/time_helpers.rb b/activesupport/lib/active_support/testing/time_helpers.rb new file mode 100644 index 0000000000..5a3fa9346c --- /dev/null +++ b/activesupport/lib/active_support/testing/time_helpers.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/redefine_method" +require "active_support/core_ext/time/calculations" +require "concurrent/map" + +module ActiveSupport + module Testing + class SimpleStubs # :nodoc: + Stub = Struct.new(:object, :method_name, :original_method) + + def initialize + @stubs = Concurrent::Map.new { |h, k| h[k] = {} } + end + + def stub_object(object, method_name, &block) + if stub = stubbing(object, method_name) + unstub_object(stub) + end + + new_name = "__simple_stub__#{method_name}" + + @stubs[object.object_id][method_name] = Stub.new(object, method_name, new_name) + + object.singleton_class.alias_method new_name, method_name + object.define_singleton_method(method_name, &block) + end + + def unstub_all! + @stubs.each_value do |object_stubs| + object_stubs.each_value do |stub| + unstub_object(stub) + end + end + @stubs.clear + end + + def stubbing(object, method_name) + @stubs[object.object_id][method_name] + end + + private + + def unstub_object(stub) + singleton_class = stub.object.singleton_class + singleton_class.silence_redefinition_of_method stub.method_name + singleton_class.alias_method stub.method_name, stub.original_method + singleton_class.undef_method stub.original_method + end + end + + # Contains helpers that help you test passage of time. + module TimeHelpers + def after_teardown + travel_back + super + end + + # Changes current time to the time in the future or in the past by a given time difference by + # stubbing +Time.now+, +Date.today+, and +DateTime.now+. The stubs are automatically removed + # at the end of the test. + # + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + # travel 1.day + # Time.current # => Sun, 10 Nov 2013 15:34:49 EST -05:00 + # Date.current # => Sun, 10 Nov 2013 + # DateTime.current # => Sun, 10 Nov 2013 15:34:49 -0500 + # + # This method also accepts a block, which will return the current time back to its original + # state at the end of the block: + # + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + # travel 1.day do + # User.create.created_at # => Sun, 10 Nov 2013 15:34:49 EST -05:00 + # end + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + def travel(duration, &block) + travel_to Time.now + duration, &block + end + + # Changes current time to the given time by stubbing +Time.now+, + # +Date.today+, and +DateTime.now+ to return the time or date passed into this method. + # The stubs are automatically removed at the end of the test. + # + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + # travel_to Time.zone.local(2004, 11, 24, 01, 04, 44) + # Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00 + # Date.current # => Wed, 24 Nov 2004 + # DateTime.current # => Wed, 24 Nov 2004 01:04:44 -0500 + # + # Dates are taken as their timestamp at the beginning of the day in the + # application time zone. <tt>Time.current</tt> returns said timestamp, + # and <tt>Time.now</tt> its equivalent in the system time zone. Similarly, + # <tt>Date.current</tt> returns a date equal to the argument, and + # <tt>Date.today</tt> the date according to <tt>Time.now</tt>, which may + # be different. (Note that you rarely want to deal with <tt>Time.now</tt>, + # or <tt>Date.today</tt>, in order to honor the application time zone + # please always use <tt>Time.current</tt> and <tt>Date.current</tt>.) + # + # Note that the usec for the time passed will be set to 0 to prevent rounding + # errors with external services, like MySQL (which will round instead of floor, + # leading to off-by-one-second errors). + # + # This method also accepts a block, which will return the current time back to its original + # state at the end of the block: + # + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + # travel_to Time.zone.local(2004, 11, 24, 01, 04, 44) do + # Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00 + # end + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + def travel_to(date_or_time) + if block_given? && simple_stubs.stubbing(Time, :now) + travel_to_nested_block_call = <<~MSG + + Calling `travel_to` with a block, when we have previously already made a call to `travel_to`, can lead to confusing time stubbing. + + Instead of: + + travel_to 2.days.from_now do + # 2 days from today + travel_to 3.days.from_now do + # 5 days from today + end + end + + preferred way to achieve above is: + + travel 2.days do + # 2 days from today + end + + travel 5.days do + # 5 days from today + end + + MSG + raise travel_to_nested_block_call + end + + if date_or_time.is_a?(Date) && !date_or_time.is_a?(DateTime) + now = date_or_time.midnight.to_time + else + now = date_or_time.to_time.change(usec: 0) + end + + simple_stubs.stub_object(Time, :now) { at(now.to_i) } + simple_stubs.stub_object(Date, :today) { jd(now.to_date.jd) } + simple_stubs.stub_object(DateTime, :now) { jd(now.to_date.jd, now.hour, now.min, now.sec, Rational(now.utc_offset, 86400)) } + + if block_given? + begin + yield + ensure + travel_back + end + end + end + + # Returns the current time back to its original state, by removing the stubs added by + # +travel+, +travel_to+, and +freeze_time+. + # + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + # travel_to Time.zone.local(2004, 11, 24, 01, 04, 44) + # Time.current # => Wed, 24 Nov 2004 01:04:44 EST -05:00 + # travel_back + # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 + def travel_back + simple_stubs.unstub_all! + end + alias_method :unfreeze_time, :travel_back + + # Calls +travel_to+ with +Time.now+. + # + # Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00 + # freeze_time + # sleep(1) + # Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00 + # + # This method also accepts a block, which will return the current time back to its original + # state at the end of the block: + # + # Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00 + # freeze_time do + # sleep(1) + # User.create.created_at # => Sun, 09 Jul 2017 15:34:49 EST -05:00 + # end + # Time.current # => Sun, 09 Jul 2017 15:34:50 EST -05:00 + def freeze_time(&block) + travel_to Time.now, &block + end + + private + + def simple_stubs + @simple_stubs ||= SimpleStubs.new + end + end + end +end diff --git a/activesupport/lib/active_support/time.rb b/activesupport/lib/active_support/time.rb new file mode 100644 index 0000000000..51854675bf --- /dev/null +++ b/activesupport/lib/active_support/time.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ActiveSupport + autoload :Duration, "active_support/duration" + autoload :TimeWithZone, "active_support/time_with_zone" + autoload :TimeZone, "active_support/values/time_zone" +end + +require "date" +require "time" + +require "active_support/core_ext/time" +require "active_support/core_ext/date" +require "active_support/core_ext/date_time" + +require "active_support/core_ext/integer/time" +require "active_support/core_ext/numeric/time" + +require "active_support/core_ext/string/conversions" +require "active_support/core_ext/string/zones" diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb new file mode 100644 index 0000000000..3be5f6f7b5 --- /dev/null +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -0,0 +1,561 @@ +# frozen_string_literal: true + +require "active_support/duration" +require "active_support/values/time_zone" +require "active_support/core_ext/object/acts_like" +require "active_support/core_ext/date_and_time/compatibility" + +module ActiveSupport + # A Time-like class that can represent a time in any time zone. Necessary + # because standard Ruby Time instances are limited to UTC and the + # system's <tt>ENV['TZ']</tt> zone. + # + # You shouldn't ever need to create a TimeWithZone instance directly via +new+. + # Instead use methods +local+, +parse+, +at+ and +now+ on TimeZone instances, + # and +in_time_zone+ on Time and DateTime instances. + # + # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' + # Time.zone.local(2007, 2, 10, 15, 30, 45) # => Sat, 10 Feb 2007 15:30:45 EST -05:00 + # Time.zone.parse('2007-02-10 15:30:45') # => Sat, 10 Feb 2007 15:30:45 EST -05:00 + # Time.zone.at(1171139445) # => Sat, 10 Feb 2007 15:30:45 EST -05:00 + # Time.zone.now # => Sun, 18 May 2008 13:07:55 EDT -04:00 + # Time.utc(2007, 2, 10, 20, 30, 45).in_time_zone # => Sat, 10 Feb 2007 15:30:45 EST -05:00 + # + # See Time and TimeZone for further documentation of these methods. + # + # TimeWithZone instances implement the same API as Ruby Time instances, so + # that Time and TimeWithZone instances are interchangeable. + # + # t = Time.zone.now # => Sun, 18 May 2008 13:27:25 EDT -04:00 + # t.hour # => 13 + # t.dst? # => true + # t.utc_offset # => -14400 + # t.zone # => "EDT" + # t.to_s(:rfc822) # => "Sun, 18 May 2008 13:27:25 -0400" + # t + 1.day # => Mon, 19 May 2008 13:27:25 EDT -04:00 + # t.beginning_of_year # => Tue, 01 Jan 2008 00:00:00 EST -05:00 + # t > Time.utc(1999) # => true + # t.is_a?(Time) # => true + # t.is_a?(ActiveSupport::TimeWithZone) # => true + class TimeWithZone + # Report class name as 'Time' to thwart type checking. + def self.name + "Time" + end + + PRECISIONS = Hash.new { |h, n| h[n] = "%FT%T.%#{n}N" } + PRECISIONS[0] = "%FT%T" + + include Comparable, DateAndTime::Compatibility + attr_reader :time_zone + + def initialize(utc_time, time_zone, local_time = nil, period = nil) + @utc = utc_time ? transfer_time_values_to_utc_constructor(utc_time) : nil + @time_zone, @time = time_zone, local_time + @period = @utc ? period : get_period_and_ensure_valid_local_time(period) + end + + # Returns a <tt>Time</tt> instance that represents the time in +time_zone+. + def time + @time ||= period.to_local(@utc) + end + + # Returns a <tt>Time</tt> instance of the simultaneous time in the UTC timezone. + def utc + @utc ||= period.to_utc(@time) + end + alias_method :comparable_time, :utc + alias_method :getgm, :utc + alias_method :getutc, :utc + alias_method :gmtime, :utc + + # Returns the underlying TZInfo::TimezonePeriod. + def period + @period ||= time_zone.period_for_utc(@utc) + end + + # Returns the simultaneous time in <tt>Time.zone</tt>, or the specified zone. + def in_time_zone(new_zone = ::Time.zone) + return self if time_zone == new_zone + utc.in_time_zone(new_zone) + end + + # Returns a <tt>Time</tt> instance of the simultaneous time in the system timezone. + def localtime(utc_offset = nil) + utc.getlocal(utc_offset) + end + alias_method :getlocal, :localtime + + # Returns true if the current time is within Daylight Savings Time for the + # specified time zone. + # + # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' + # Time.zone.parse("2012-5-30").dst? # => true + # Time.zone.parse("2012-11-30").dst? # => false + def dst? + period.dst? + end + alias_method :isdst, :dst? + + # Returns true if the current time zone is set to UTC. + # + # Time.zone = 'UTC' # => 'UTC' + # Time.zone.now.utc? # => true + # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' + # Time.zone.now.utc? # => false + def utc? + period.offset.abbreviation == :UTC || period.offset.abbreviation == :UCT + end + alias_method :gmt?, :utc? + + # Returns the offset from current time to UTC time in seconds. + def utc_offset + period.utc_total_offset + end + alias_method :gmt_offset, :utc_offset + alias_method :gmtoff, :utc_offset + + # Returns a formatted string of the offset from UTC, or an alternative + # string if the time zone is already UTC. + # + # Time.zone = 'Eastern Time (US & Canada)' # => "Eastern Time (US & Canada)" + # Time.zone.now.formatted_offset(true) # => "-05:00" + # Time.zone.now.formatted_offset(false) # => "-0500" + # Time.zone = 'UTC' # => "UTC" + # Time.zone.now.formatted_offset(true, "0") # => "0" + def formatted_offset(colon = true, alternate_utc_string = nil) + utc? && alternate_utc_string || TimeZone.seconds_to_utc_offset(utc_offset, colon) + end + + # Returns the time zone abbreviation. + # + # Time.zone = 'Eastern Time (US & Canada)' # => "Eastern Time (US & Canada)" + # Time.zone.now.zone # => "EST" + def zone + period.zone_identifier.to_s + end + + # Returns a string of the object's date, time, zone, and offset from UTC. + # + # Time.zone.now.inspect # => "Thu, 04 Dec 2014 11:00:25 EST -05:00" + def inspect + "#{time.strftime('%a, %d %b %Y %H:%M:%S')} #{zone} #{formatted_offset}" + end + + # Returns a string of the object's date and time in the ISO 8601 standard + # format. + # + # Time.zone.now.xmlschema # => "2014-12-04T11:02:37-05:00" + def xmlschema(fraction_digits = 0) + "#{time.strftime(PRECISIONS[fraction_digits.to_i])}#{formatted_offset(true, 'Z')}" + end + alias_method :iso8601, :xmlschema + alias_method :rfc3339, :xmlschema + + # Coerces time to a string for JSON encoding. The default format is ISO 8601. + # You can get %Y/%m/%d %H:%M:%S +offset style by setting + # <tt>ActiveSupport::JSON::Encoding.use_standard_json_time_format</tt> + # to +false+. + # + # # With ActiveSupport::JSON::Encoding.use_standard_json_time_format = true + # Time.utc(2005,2,1,15,15,10).in_time_zone("Hawaii").to_json + # # => "2005-02-01T05:15:10.000-10:00" + # + # # With ActiveSupport::JSON::Encoding.use_standard_json_time_format = false + # Time.utc(2005,2,1,15,15,10).in_time_zone("Hawaii").to_json + # # => "2005/02/01 05:15:10 -1000" + def as_json(options = nil) + if ActiveSupport::JSON::Encoding.use_standard_json_time_format + xmlschema(ActiveSupport::JSON::Encoding.time_precision) + else + %(#{time.strftime("%Y/%m/%d %H:%M:%S")} #{formatted_offset(false)}) + end + end + + def init_with(coder) #:nodoc: + initialize(coder["utc"], coder["zone"], coder["time"]) + end + + def encode_with(coder) #:nodoc: + coder.tag = "!ruby/object:ActiveSupport::TimeWithZone" + coder.map = { "utc" => utc, "zone" => time_zone, "time" => time } + end + + # Returns a string of the object's date and time in the format used by + # HTTP requests. + # + # Time.zone.now.httpdate # => "Tue, 01 Jan 2013 04:39:43 GMT" + def httpdate + utc.httpdate + end + + # Returns a string of the object's date and time in the RFC 2822 standard + # format. + # + # Time.zone.now.rfc2822 # => "Tue, 01 Jan 2013 04:51:39 +0000" + def rfc2822 + to_s(:rfc822) + end + alias_method :rfc822, :rfc2822 + + # Returns a string of the object's date and time. + # Accepts an optional <tt>format</tt>: + # * <tt>:default</tt> - default value, mimics Ruby Time#to_s format. + # * <tt>:db</tt> - format outputs time in UTC :db time. See Time#to_formatted_s(:db). + # * Any key in <tt>Time::DATE_FORMATS</tt> can be used. See active_support/core_ext/time/conversions.rb. + def to_s(format = :default) + if format == :db + utc.to_s(format) + elsif formatter = ::Time::DATE_FORMATS[format] + formatter.respond_to?(:call) ? formatter.call(self).to_s : strftime(formatter) + else + "#{time.strftime("%Y-%m-%d %H:%M:%S")} #{formatted_offset(false, 'UTC')}" # mimicking Ruby Time#to_s format + end + end + alias_method :to_formatted_s, :to_s + + # Replaces <tt>%Z</tt> directive with +zone before passing to Time#strftime, + # so that zone information is correct. + def strftime(format) + format = format.gsub(/((?:\A|[^%])(?:%%)*)%Z/, "\\1#{zone}") + getlocal(utc_offset).strftime(format) + end + + # Use the time in UTC for comparisons. + def <=>(other) + utc <=> other + end + alias_method :before?, :< + alias_method :after?, :> + + # Returns true if the current object's time is within the specified + # +min+ and +max+ time. + def between?(min, max) + utc.between?(min, max) + end + + # Returns true if the current object's time is in the past. + def past? + utc.past? + end + + # Returns true if the current object's time falls within + # the current day. + def today? + time.today? + end + + # Returns true if the current object's time is in the future. + def future? + utc.future? + end + + # Returns +true+ if +other+ is equal to current object. + def eql?(other) + other.eql?(utc) + end + + def hash + utc.hash + end + + # Adds an interval of time to the current object's time and returns that + # value as a new TimeWithZone object. + # + # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' + # now = Time.zone.now # => Sun, 02 Nov 2014 01:26:28 EDT -04:00 + # now + 1000 # => Sun, 02 Nov 2014 01:43:08 EDT -04:00 + # + # If we're adding a Duration of variable length (i.e., years, months, days), + # move forward from #time, otherwise move forward from #utc, for accuracy + # when moving across DST boundaries. + # + # For instance, a time + 24.hours will advance exactly 24 hours, while a + # time + 1.day will advance 23-25 hours, depending on the day. + # + # now + 24.hours # => Mon, 03 Nov 2014 00:26:28 EST -05:00 + # now + 1.day # => Mon, 03 Nov 2014 01:26:28 EST -05:00 + def +(other) + if duration_of_variable_length?(other) + method_missing(:+, other) + else + result = utc.acts_like?(:date) ? utc.since(other) : utc + other rescue utc.since(other) + result.in_time_zone(time_zone) + end + end + alias_method :since, :+ + alias_method :in, :+ + + # Subtracts an interval of time and returns a new TimeWithZone object unless + # the other value `acts_like?` time. Then it will return a Float of the difference + # between the two times that represents the difference between the current + # object's time and the +other+ time. + # + # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' + # now = Time.zone.now # => Mon, 03 Nov 2014 00:26:28 EST -05:00 + # now - 1000 # => Mon, 03 Nov 2014 00:09:48 EST -05:00 + # + # If subtracting a Duration of variable length (i.e., years, months, days), + # move backward from #time, otherwise move backward from #utc, for accuracy + # when moving across DST boundaries. + # + # For instance, a time - 24.hours will go subtract exactly 24 hours, while a + # time - 1.day will subtract 23-25 hours, depending on the day. + # + # now - 24.hours # => Sun, 02 Nov 2014 01:26:28 EDT -04:00 + # now - 1.day # => Sun, 02 Nov 2014 00:26:28 EDT -04:00 + # + # If both the TimeWithZone object and the other value act like Time, a Float + # will be returned. + # + # Time.zone.now - 1.day.ago # => 86399.999967 + # + def -(other) + if other.acts_like?(:time) + to_time - other.to_time + elsif duration_of_variable_length?(other) + method_missing(:-, other) + else + result = utc.acts_like?(:date) ? utc.ago(other) : utc - other rescue utc.ago(other) + result.in_time_zone(time_zone) + end + end + + # Subtracts an interval of time from the current object's time and returns + # the result as a new TimeWithZone object. + # + # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' + # now = Time.zone.now # => Mon, 03 Nov 2014 00:26:28 EST -05:00 + # now.ago(1000) # => Mon, 03 Nov 2014 00:09:48 EST -05:00 + # + # If we're subtracting a Duration of variable length (i.e., years, months, + # days), move backward from #time, otherwise move backward from #utc, for + # accuracy when moving across DST boundaries. + # + # For instance, <tt>time.ago(24.hours)</tt> will move back exactly 24 hours, + # while <tt>time.ago(1.day)</tt> will move back 23-25 hours, depending on + # the day. + # + # now.ago(24.hours) # => Sun, 02 Nov 2014 01:26:28 EDT -04:00 + # now.ago(1.day) # => Sun, 02 Nov 2014 00:26:28 EDT -04:00 + def ago(other) + since(-other) + end + + # Returns a new +ActiveSupport::TimeWithZone+ where one or more of the elements have + # been changed according to the +options+ parameter. The time options (<tt>:hour</tt>, + # <tt>:min</tt>, <tt>:sec</tt>, <tt>:usec</tt>, <tt>:nsec</tt>) reset cascadingly, + # so if only the hour is passed, then minute, sec, usec and nsec is set to 0. If the + # hour and minute is passed, then sec, usec and nsec is set to 0. The +options+ + # parameter takes a hash with any of these keys: <tt>:year</tt>, <tt>:month</tt>, + # <tt>:day</tt>, <tt>:hour</tt>, <tt>:min</tt>, <tt>:sec</tt>, <tt>:usec</tt>, + # <tt>:nsec</tt>, <tt>:offset</tt>, <tt>:zone</tt>. Pass either <tt>:usec</tt> + # or <tt>:nsec</tt>, not both. Similarly, pass either <tt>:zone</tt> or + # <tt>:offset</tt>, not both. + # + # t = Time.zone.now # => Fri, 14 Apr 2017 11:45:15 EST -05:00 + # t.change(year: 2020) # => Tue, 14 Apr 2020 11:45:15 EST -05:00 + # t.change(hour: 12) # => Fri, 14 Apr 2017 12:00:00 EST -05:00 + # t.change(min: 30) # => Fri, 14 Apr 2017 11:30:00 EST -05:00 + # t.change(offset: "-10:00") # => Fri, 14 Apr 2017 11:45:15 HST -10:00 + # t.change(zone: "Hawaii") # => Fri, 14 Apr 2017 11:45:15 HST -10:00 + def change(options) + if options[:zone] && options[:offset] + raise ArgumentError, "Can't change both :offset and :zone at the same time: #{options.inspect}" + end + + new_time = time.change(options) + + if options[:zone] + new_zone = ::Time.find_zone(options[:zone]) + elsif options[:offset] + new_zone = ::Time.find_zone(new_time.utc_offset) + end + + new_zone ||= time_zone + periods = new_zone.periods_for_local(new_time) + + self.class.new(nil, new_zone, new_time, periods.include?(period) ? period : nil) + end + + # Uses Date to provide precise Time calculations for years, months, and days + # according to the proleptic Gregorian calendar. The result is returned as a + # new TimeWithZone object. + # + # The +options+ parameter takes a hash with any of these keys: + # <tt>:years</tt>, <tt>:months</tt>, <tt>:weeks</tt>, <tt>:days</tt>, + # <tt>:hours</tt>, <tt>:minutes</tt>, <tt>:seconds</tt>. + # + # If advancing by a value of variable length (i.e., years, weeks, months, + # days), move forward from #time, otherwise move forward from #utc, for + # accuracy when moving across DST boundaries. + # + # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' + # now = Time.zone.now # => Sun, 02 Nov 2014 01:26:28 EDT -04:00 + # now.advance(seconds: 1) # => Sun, 02 Nov 2014 01:26:29 EDT -04:00 + # now.advance(minutes: 1) # => Sun, 02 Nov 2014 01:27:28 EDT -04:00 + # now.advance(hours: 1) # => Sun, 02 Nov 2014 01:26:28 EST -05:00 + # now.advance(days: 1) # => Mon, 03 Nov 2014 01:26:28 EST -05:00 + # now.advance(weeks: 1) # => Sun, 09 Nov 2014 01:26:28 EST -05:00 + # now.advance(months: 1) # => Tue, 02 Dec 2014 01:26:28 EST -05:00 + # now.advance(years: 1) # => Mon, 02 Nov 2015 01:26:28 EST -05:00 + def advance(options) + # If we're advancing a value of variable length (i.e., years, weeks, months, days), advance from #time, + # otherwise advance from #utc, for accuracy when moving across DST boundaries + if options.values_at(:years, :weeks, :months, :days).any? + method_missing(:advance, options) + else + utc.advance(options).in_time_zone(time_zone) + end + end + + %w(year mon month day mday wday yday hour min sec usec nsec to_date).each do |method_name| + class_eval <<-EOV, __FILE__, __LINE__ + 1 + def #{method_name} # def month + time.#{method_name} # time.month + end # end + EOV + end + + # Returns Array of parts of Time in sequence of + # [seconds, minutes, hours, day, month, year, weekday, yearday, dst?, zone]. + # + # now = Time.zone.now # => Tue, 18 Aug 2015 02:29:27 UTC +00:00 + # now.to_a # => [27, 29, 2, 18, 8, 2015, 2, 230, false, "UTC"] + def to_a + [time.sec, time.min, time.hour, time.day, time.mon, time.year, time.wday, time.yday, dst?, zone] + end + + # Returns the object's date and time as a floating point number of seconds + # since the Epoch (January 1, 1970 00:00 UTC). + # + # Time.zone.now.to_f # => 1417709320.285418 + def to_f + utc.to_f + end + + # Returns the object's date and time as an integer number of seconds + # since the Epoch (January 1, 1970 00:00 UTC). + # + # Time.zone.now.to_i # => 1417709320 + def to_i + utc.to_i + end + alias_method :tv_sec, :to_i + + # Returns the object's date and time as a rational number of seconds + # since the Epoch (January 1, 1970 00:00 UTC). + # + # Time.zone.now.to_r # => (708854548642709/500000) + def to_r + utc.to_r + end + + # Returns an instance of DateTime with the timezone's UTC offset + # + # Time.zone.now.to_datetime # => Tue, 18 Aug 2015 02:32:20 +0000 + # Time.current.in_time_zone('Hawaii').to_datetime # => Mon, 17 Aug 2015 16:32:20 -1000 + def to_datetime + @to_datetime ||= utc.to_datetime.new_offset(Rational(utc_offset, 86_400)) + end + + # Returns an instance of +Time+, either with the same UTC offset + # as +self+ or in the local system timezone depending on the setting + # of +ActiveSupport.to_time_preserves_timezone+. + def to_time + if preserve_timezone + @to_time_with_instance_offset ||= getlocal(utc_offset) + else + @to_time_with_system_offset ||= getlocal + end + end + + # So that +self+ <tt>acts_like?(:time)</tt>. + def acts_like_time? + true + end + + # Say we're a Time to thwart type checking. + def is_a?(klass) + klass == ::Time || super + end + alias_method :kind_of?, :is_a? + + # An instance of ActiveSupport::TimeWithZone is never blank + def blank? + false + end + + def freeze + # preload instance variables before freezing + period; utc; time; to_datetime; to_time + super + end + + def marshal_dump + [utc, time_zone.name, time] + end + + def marshal_load(variables) + initialize(variables[0].utc, ::Time.find_zone(variables[1]), variables[2].utc) + end + + # respond_to_missing? is not called in some cases, such as when type conversion is + # performed with Kernel#String + def respond_to?(sym, include_priv = false) + # ensure that we're not going to throw and rescue from NoMethodError in method_missing which is slow + return false if sym.to_sym == :to_str + super + end + + # Ensure proxy class responds to all methods that underlying time instance + # responds to. + def respond_to_missing?(sym, include_priv) + return false if sym.to_sym == :acts_like_date? + time.respond_to?(sym, include_priv) + end + + # Send the missing method to +time+ instance, and wrap result in a new + # TimeWithZone with the existing +time_zone+. + def method_missing(sym, *args, &block) + wrap_with_time_zone time.__send__(sym, *args, &block) + rescue NoMethodError => e + raise e, e.message.sub(time.inspect, inspect), e.backtrace + end + + private + def get_period_and_ensure_valid_local_time(period) + # we don't want a Time.local instance enforcing its own DST rules as well, + # so transfer time values to a utc constructor if necessary + @time = transfer_time_values_to_utc_constructor(@time) unless @time.utc? + begin + period || @time_zone.period_for_local(@time) + rescue ::TZInfo::PeriodNotFound + # time is in the "spring forward" hour gap, so we're moving the time forward one hour and trying again + @time += 1.hour + retry + end + end + + def transfer_time_values_to_utc_constructor(time) + # avoid creating another Time object if possible + return time if time.instance_of?(::Time) && time.utc? + ::Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec + time.subsec) + end + + def duration_of_variable_length?(obj) + ActiveSupport::Duration === obj && obj.parts.any? { |p| [:years, :months, :weeks, :days].include?(p[0]) } + end + + def wrap_with_time_zone(time) + if time.acts_like?(:time) + periods = time_zone.periods_for_local(time) + self.class.new(nil, time_zone, time, periods.include?(period) ? period : nil) + elsif time.is_a?(Range) + wrap_with_time_zone(time.begin)..wrap_with_time_zone(time.end) + else + time + end + end + end +end diff --git a/activesupport/lib/active_support/values/time_zone.rb b/activesupport/lib/active_support/values/time_zone.rb new file mode 100644 index 0000000000..d9e033e23b --- /dev/null +++ b/activesupport/lib/active_support/values/time_zone.rb @@ -0,0 +1,570 @@ +# frozen_string_literal: true + +require "tzinfo" +require "concurrent/map" + +module ActiveSupport + # The TimeZone class serves as a wrapper around TZInfo::Timezone instances. + # It allows us to do the following: + # + # * Limit the set of zones provided by TZInfo to a meaningful subset of 134 + # zones. + # * Retrieve and display zones with a friendlier name + # (e.g., "Eastern Time (US & Canada)" instead of "America/New_York"). + # * Lazily load TZInfo::Timezone instances only when they're needed. + # * Create ActiveSupport::TimeWithZone instances via TimeZone's +local+, + # +parse+, +at+ and +now+ methods. + # + # If you set <tt>config.time_zone</tt> in the Rails Application, you can + # access this TimeZone object via <tt>Time.zone</tt>: + # + # # application.rb: + # class Application < Rails::Application + # config.time_zone = 'Eastern Time (US & Canada)' + # end + # + # Time.zone # => #<ActiveSupport::TimeZone:0x514834...> + # Time.zone.name # => "Eastern Time (US & Canada)" + # Time.zone.now # => Sun, 18 May 2008 14:30:44 EDT -04:00 + class TimeZone + # Keys are Rails TimeZone names, values are TZInfo identifiers. + MAPPING = { + "International Date Line West" => "Etc/GMT+12", + "Midway Island" => "Pacific/Midway", + "American Samoa" => "Pacific/Pago_Pago", + "Hawaii" => "Pacific/Honolulu", + "Alaska" => "America/Juneau", + "Pacific Time (US & Canada)" => "America/Los_Angeles", + "Tijuana" => "America/Tijuana", + "Mountain Time (US & Canada)" => "America/Denver", + "Arizona" => "America/Phoenix", + "Chihuahua" => "America/Chihuahua", + "Mazatlan" => "America/Mazatlan", + "Central Time (US & Canada)" => "America/Chicago", + "Saskatchewan" => "America/Regina", + "Guadalajara" => "America/Mexico_City", + "Mexico City" => "America/Mexico_City", + "Monterrey" => "America/Monterrey", + "Central America" => "America/Guatemala", + "Eastern Time (US & Canada)" => "America/New_York", + "Indiana (East)" => "America/Indiana/Indianapolis", + "Bogota" => "America/Bogota", + "Lima" => "America/Lima", + "Quito" => "America/Lima", + "Atlantic Time (Canada)" => "America/Halifax", + "Caracas" => "America/Caracas", + "La Paz" => "America/La_Paz", + "Santiago" => "America/Santiago", + "Newfoundland" => "America/St_Johns", + "Brasilia" => "America/Sao_Paulo", + "Buenos Aires" => "America/Argentina/Buenos_Aires", + "Montevideo" => "America/Montevideo", + "Georgetown" => "America/Guyana", + "Puerto Rico" => "America/Puerto_Rico", + "Greenland" => "America/Godthab", + "Mid-Atlantic" => "Atlantic/South_Georgia", + "Azores" => "Atlantic/Azores", + "Cape Verde Is." => "Atlantic/Cape_Verde", + "Dublin" => "Europe/Dublin", + "Edinburgh" => "Europe/London", + "Lisbon" => "Europe/Lisbon", + "London" => "Europe/London", + "Casablanca" => "Africa/Casablanca", + "Monrovia" => "Africa/Monrovia", + "UTC" => "Etc/UTC", + "Belgrade" => "Europe/Belgrade", + "Bratislava" => "Europe/Bratislava", + "Budapest" => "Europe/Budapest", + "Ljubljana" => "Europe/Ljubljana", + "Prague" => "Europe/Prague", + "Sarajevo" => "Europe/Sarajevo", + "Skopje" => "Europe/Skopje", + "Warsaw" => "Europe/Warsaw", + "Zagreb" => "Europe/Zagreb", + "Brussels" => "Europe/Brussels", + "Copenhagen" => "Europe/Copenhagen", + "Madrid" => "Europe/Madrid", + "Paris" => "Europe/Paris", + "Amsterdam" => "Europe/Amsterdam", + "Berlin" => "Europe/Berlin", + "Bern" => "Europe/Zurich", + "Zurich" => "Europe/Zurich", + "Rome" => "Europe/Rome", + "Stockholm" => "Europe/Stockholm", + "Vienna" => "Europe/Vienna", + "West Central Africa" => "Africa/Algiers", + "Bucharest" => "Europe/Bucharest", + "Cairo" => "Africa/Cairo", + "Helsinki" => "Europe/Helsinki", + "Kyiv" => "Europe/Kiev", + "Riga" => "Europe/Riga", + "Sofia" => "Europe/Sofia", + "Tallinn" => "Europe/Tallinn", + "Vilnius" => "Europe/Vilnius", + "Athens" => "Europe/Athens", + "Istanbul" => "Europe/Istanbul", + "Minsk" => "Europe/Minsk", + "Jerusalem" => "Asia/Jerusalem", + "Harare" => "Africa/Harare", + "Pretoria" => "Africa/Johannesburg", + "Kaliningrad" => "Europe/Kaliningrad", + "Moscow" => "Europe/Moscow", + "St. Petersburg" => "Europe/Moscow", + "Volgograd" => "Europe/Volgograd", + "Samara" => "Europe/Samara", + "Kuwait" => "Asia/Kuwait", + "Riyadh" => "Asia/Riyadh", + "Nairobi" => "Africa/Nairobi", + "Baghdad" => "Asia/Baghdad", + "Tehran" => "Asia/Tehran", + "Abu Dhabi" => "Asia/Muscat", + "Muscat" => "Asia/Muscat", + "Baku" => "Asia/Baku", + "Tbilisi" => "Asia/Tbilisi", + "Yerevan" => "Asia/Yerevan", + "Kabul" => "Asia/Kabul", + "Ekaterinburg" => "Asia/Yekaterinburg", + "Islamabad" => "Asia/Karachi", + "Karachi" => "Asia/Karachi", + "Tashkent" => "Asia/Tashkent", + "Chennai" => "Asia/Kolkata", + "Kolkata" => "Asia/Kolkata", + "Mumbai" => "Asia/Kolkata", + "New Delhi" => "Asia/Kolkata", + "Kathmandu" => "Asia/Kathmandu", + "Astana" => "Asia/Dhaka", + "Dhaka" => "Asia/Dhaka", + "Sri Jayawardenepura" => "Asia/Colombo", + "Almaty" => "Asia/Almaty", + "Novosibirsk" => "Asia/Novosibirsk", + "Rangoon" => "Asia/Rangoon", + "Bangkok" => "Asia/Bangkok", + "Hanoi" => "Asia/Bangkok", + "Jakarta" => "Asia/Jakarta", + "Krasnoyarsk" => "Asia/Krasnoyarsk", + "Beijing" => "Asia/Shanghai", + "Chongqing" => "Asia/Chongqing", + "Hong Kong" => "Asia/Hong_Kong", + "Urumqi" => "Asia/Urumqi", + "Kuala Lumpur" => "Asia/Kuala_Lumpur", + "Singapore" => "Asia/Singapore", + "Taipei" => "Asia/Taipei", + "Perth" => "Australia/Perth", + "Irkutsk" => "Asia/Irkutsk", + "Ulaanbaatar" => "Asia/Ulaanbaatar", + "Seoul" => "Asia/Seoul", + "Osaka" => "Asia/Tokyo", + "Sapporo" => "Asia/Tokyo", + "Tokyo" => "Asia/Tokyo", + "Yakutsk" => "Asia/Yakutsk", + "Darwin" => "Australia/Darwin", + "Adelaide" => "Australia/Adelaide", + "Canberra" => "Australia/Melbourne", + "Melbourne" => "Australia/Melbourne", + "Sydney" => "Australia/Sydney", + "Brisbane" => "Australia/Brisbane", + "Hobart" => "Australia/Hobart", + "Vladivostok" => "Asia/Vladivostok", + "Guam" => "Pacific/Guam", + "Port Moresby" => "Pacific/Port_Moresby", + "Magadan" => "Asia/Magadan", + "Srednekolymsk" => "Asia/Srednekolymsk", + "Solomon Is." => "Pacific/Guadalcanal", + "New Caledonia" => "Pacific/Noumea", + "Fiji" => "Pacific/Fiji", + "Kamchatka" => "Asia/Kamchatka", + "Marshall Is." => "Pacific/Majuro", + "Auckland" => "Pacific/Auckland", + "Wellington" => "Pacific/Auckland", + "Nuku'alofa" => "Pacific/Tongatapu", + "Tokelau Is." => "Pacific/Fakaofo", + "Chatham Is." => "Pacific/Chatham", + "Samoa" => "Pacific/Apia" + } + + UTC_OFFSET_WITH_COLON = "%s%02d:%02d" # :nodoc: + UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.tr(":", "") # :nodoc: + private_constant :UTC_OFFSET_WITH_COLON, :UTC_OFFSET_WITHOUT_COLON + + @lazy_zones_map = Concurrent::Map.new + @country_zones = Concurrent::Map.new + + class << self + # Assumes self represents an offset from UTC in seconds (as returned from + # Time#utc_offset) and turns this into an +HH:MM formatted string. + # + # ActiveSupport::TimeZone.seconds_to_utc_offset(-21_600) # => "-06:00" + def seconds_to_utc_offset(seconds, colon = true) + format = colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON + sign = (seconds < 0 ? "-" : "+") + hours = seconds.abs / 3600 + minutes = (seconds.abs % 3600) / 60 + format % [sign, hours, minutes] + end + + def find_tzinfo(name) + TZInfo::Timezone.new(MAPPING[name] || name) + end + + alias_method :create, :new + + # Returns a TimeZone instance with the given name, or +nil+ if no + # such TimeZone instance exists. (This exists to support the use of + # this class with the +composed_of+ macro.) + def new(name) + self[name] + end + + # Returns an array of all TimeZone objects. There are multiple + # TimeZone objects per time zone, in many cases, to make it easier + # for users to find their own time zone. + def all + @zones ||= zones_map.values.sort + end + + # Locate a specific time zone object. If the argument is a string, it + # is interpreted to mean the name of the timezone to locate. If it is a + # numeric value it is either the hour offset, or the second offset, of the + # timezone to find. (The first one with that offset will be returned.) + # Returns +nil+ if no such time zone is known to the system. + def [](arg) + case arg + when String + begin + @lazy_zones_map[arg] ||= create(arg) + rescue TZInfo::InvalidTimezoneIdentifier + nil + end + when Numeric, ActiveSupport::Duration + arg *= 3600 if arg.abs <= 13 + all.find { |z| z.utc_offset == arg.to_i } + else + raise ArgumentError, "invalid argument to TimeZone[]: #{arg.inspect}" + end + end + + # A convenience method for returning a collection of TimeZone objects + # for time zones in the USA. + def us_zones + country_zones(:us) + end + + # A convenience method for returning a collection of TimeZone objects + # for time zones in the country specified by its ISO 3166-1 Alpha2 code. + def country_zones(country_code) + code = country_code.to_s.upcase + @country_zones[code] ||= load_country_zones(code) + end + + def clear #:nodoc: + @lazy_zones_map = Concurrent::Map.new + @country_zones = Concurrent::Map.new + @zones = nil + @zones_map = nil + end + + private + def load_country_zones(code) + country = TZInfo::Country.get(code) + country.zone_identifiers.flat_map do |tz_id| + if MAPPING.value?(tz_id) + MAPPING.inject([]) do |memo, (key, value)| + memo << self[key] if value == tz_id + memo + end + else + create(tz_id, nil, TZInfo::Timezone.new(tz_id)) + end + end.sort! + end + + def zones_map + @zones_map ||= MAPPING.each_with_object({}) do |(name, _), zones| + timezone = self[name] + zones[name] = timezone if timezone + end + end + end + + include Comparable + attr_reader :name + attr_reader :tzinfo + + # Create a new TimeZone object with the given name and offset. The + # offset is the number of seconds that this time zone is offset from UTC + # (GMT). Seconds were chosen as the offset unit because that is the unit + # that Ruby uses to represent time zone offsets (see Time#utc_offset). + def initialize(name, utc_offset = nil, tzinfo = nil) + @name = name + @utc_offset = utc_offset + @tzinfo = tzinfo || TimeZone.find_tzinfo(name) + end + + # Returns the offset of this time zone from UTC in seconds. + def utc_offset + if @utc_offset + @utc_offset + else + tzinfo.current_period.utc_offset if tzinfo && tzinfo.current_period + end + end + + # Returns a formatted string of the offset from UTC, or an alternative + # string if the time zone is already UTC. + # + # zone = ActiveSupport::TimeZone['Central Time (US & Canada)'] + # zone.formatted_offset # => "-06:00" + # zone.formatted_offset(false) # => "-0600" + def formatted_offset(colon = true, alternate_utc_string = nil) + utc_offset == 0 && alternate_utc_string || self.class.seconds_to_utc_offset(utc_offset, colon) + end + + # Compare this time zone to the parameter. The two are compared first on + # their offsets, and then by name. + def <=>(zone) + return unless zone.respond_to? :utc_offset + result = (utc_offset <=> zone.utc_offset) + result = (name <=> zone.name) if result == 0 + result + end + + # Compare #name and TZInfo identifier to a supplied regexp, returning +true+ + # if a match is found. + def =~(re) + re === name || re === MAPPING[name] + end + + # Returns a textual representation of this time zone. + def to_s + "(GMT#{formatted_offset}) #{name}" + end + + # Method for creating new ActiveSupport::TimeWithZone instance in time zone + # of +self+ from given values. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.local(2007, 2, 1, 15, 30, 45) # => Thu, 01 Feb 2007 15:30:45 HST -10:00 + def local(*args) + time = Time.utc(*args) + ActiveSupport::TimeWithZone.new(nil, self, time) + end + + # Method for creating new ActiveSupport::TimeWithZone instance in time zone + # of +self+ from number of seconds since the Unix epoch. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.utc(2000).to_f # => 946684800.0 + # Time.zone.at(946684800.0) # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # + # A second argument can be supplied to specify sub-second precision. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.at(946684800, 123456.789).nsec # => 123456789 + def at(*args) + Time.at(*args).utc.in_time_zone(self) + end + + # Method for creating new ActiveSupport::TimeWithZone instance in time zone + # of +self+ from an ISO 8601 string. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.iso8601('1999-12-31T14:00:00') # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # + # If the time components are missing then they will be set to zero. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.iso8601('1999-12-31') # => Fri, 31 Dec 1999 00:00:00 HST -10:00 + # + # If the string is invalid then an +ArgumentError+ will be raised unlike +parse+ + # which usually returns +nil+ when given an invalid date string. + def iso8601(str) + parts = Date._iso8601(str) + + raise ArgumentError, "invalid date" if parts.empty? + + time = Time.new( + parts.fetch(:year), + parts.fetch(:mon), + parts.fetch(:mday), + parts.fetch(:hour, 0), + parts.fetch(:min, 0), + parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset, 0) + ) + + if parts[:offset] + TimeWithZone.new(time.utc, self) + else + TimeWithZone.new(nil, self, time) + end + end + + # Method for creating new ActiveSupport::TimeWithZone instance in time zone + # of +self+ from parsed string. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.parse('1999-12-31 14:00:00') # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # + # If upper components are missing from the string, they are supplied from + # TimeZone#now: + # + # Time.zone.now # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # Time.zone.parse('22:30:00') # => Fri, 31 Dec 1999 22:30:00 HST -10:00 + # + # However, if the date component is not provided, but any other upper + # components are supplied, then the day of the month defaults to 1: + # + # Time.zone.parse('Mar 2000') # => Wed, 01 Mar 2000 00:00:00 HST -10:00 + # + # If the string is invalid then an +ArgumentError+ could be raised. + def parse(str, now = now()) + parts_to_time(Date._parse(str, false), now) + end + + # Method for creating new ActiveSupport::TimeWithZone instance in time zone + # of +self+ from an RFC 3339 string. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.rfc3339('2000-01-01T00:00:00Z') # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # + # If the time or zone components are missing then an +ArgumentError+ will + # be raised. This is much stricter than either +parse+ or +iso8601+ which + # allow for missing components. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.rfc3339('1999-12-31') # => ArgumentError: invalid date + def rfc3339(str) + parts = Date._rfc3339(str) + + raise ArgumentError, "invalid date" if parts.empty? + + time = Time.new( + parts.fetch(:year), + parts.fetch(:mon), + parts.fetch(:mday), + parts.fetch(:hour), + parts.fetch(:min), + parts.fetch(:sec) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset) + ) + + TimeWithZone.new(time.utc, self) + end + + # Parses +str+ according to +format+ and returns an ActiveSupport::TimeWithZone. + # + # Assumes that +str+ is a time in the time zone +self+, + # unless +format+ includes an explicit time zone. + # (This is the same behavior as +parse+.) + # In either case, the returned TimeWithZone has the timezone of +self+. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.strptime('1999-12-31 14:00:00', '%Y-%m-%d %H:%M:%S') # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # + # If upper components are missing from the string, they are supplied from + # TimeZone#now: + # + # Time.zone.now # => Fri, 31 Dec 1999 14:00:00 HST -10:00 + # Time.zone.strptime('22:30:00', '%H:%M:%S') # => Fri, 31 Dec 1999 22:30:00 HST -10:00 + # + # However, if the date component is not provided, but any other upper + # components are supplied, then the day of the month defaults to 1: + # + # Time.zone.strptime('Mar 2000', '%b %Y') # => Wed, 01 Mar 2000 00:00:00 HST -10:00 + def strptime(str, format, now = now()) + parts_to_time(DateTime._strptime(str, format), now) + end + + # Returns an ActiveSupport::TimeWithZone instance representing the current + # time in the time zone represented by +self+. + # + # Time.zone = 'Hawaii' # => "Hawaii" + # Time.zone.now # => Wed, 23 Jan 2008 20:24:27 HST -10:00 + def now + time_now.utc.in_time_zone(self) + end + + # Returns the current date in this time zone. + def today + tzinfo.now.to_date + end + + # Returns the next date in this time zone. + def tomorrow + today + 1 + end + + # Returns the previous date in this time zone. + def yesterday + today - 1 + end + + # Adjust the given time to the simultaneous time in the time zone + # represented by +self+. Returns a Time.utc() instance -- if you want an + # ActiveSupport::TimeWithZone instance, use Time#in_time_zone() instead. + def utc_to_local(time) + tzinfo.utc_to_local(time) + end + + # Adjust the given time to the simultaneous time in UTC. Returns a + # Time.utc() instance. + def local_to_utc(time, dst = true) + tzinfo.local_to_utc(time, dst) + end + + # Available so that TimeZone instances respond like TZInfo::Timezone + # instances. + def period_for_utc(time) + tzinfo.period_for_utc(time) + end + + # Available so that TimeZone instances respond like TZInfo::Timezone + # instances. + def period_for_local(time, dst = true) + tzinfo.period_for_local(time, dst) { |periods| periods.last } + end + + def periods_for_local(time) #:nodoc: + tzinfo.periods_for_local(time) + end + + def init_with(coder) #:nodoc: + initialize(coder["name"]) + end + + def encode_with(coder) #:nodoc: + coder.tag = "!ruby/object:#{self.class}" + coder.map = { "name" => tzinfo.name } + end + + private + def parts_to_time(parts, now) + raise ArgumentError, "invalid date" if parts.nil? + return if parts.empty? + + if parts[:seconds] + time = Time.at(parts[:seconds]) + else + time = Time.new( + parts.fetch(:year, now.year), + parts.fetch(:mon, now.month), + parts.fetch(:mday, parts[:year] || parts[:mon] ? 1 : now.day), + parts.fetch(:hour, 0), + parts.fetch(:min, 0), + parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0), + parts.fetch(:offset, 0) + ) + end + + if parts[:offset] || parts[:seconds] + TimeWithZone.new(time.utc, self) + else + TimeWithZone.new(nil, self, time) + end + end + + def time_now + Time.now + end + end +end diff --git a/activesupport/lib/active_support/version.rb b/activesupport/lib/active_support/version.rb new file mode 100644 index 0000000000..928838c837 --- /dev/null +++ b/activesupport/lib/active_support/version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "gem_version" + +module ActiveSupport + # Returns the version of the currently loaded ActiveSupport as a <tt>Gem::Version</tt> + def self.version + gem_version + end +end diff --git a/activesupport/lib/active_support/xml_mini.rb b/activesupport/lib/active_support/xml_mini.rb new file mode 100644 index 0000000000..075cd4ed8b --- /dev/null +++ b/activesupport/lib/active_support/xml_mini.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "time" +require "base64" +require "bigdecimal" +require "bigdecimal/util" +require "active_support/core_ext/module/delegation" +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/date_time/calculations" + +module ActiveSupport + # = XmlMini + # + # To use the much faster libxml parser: + # gem 'libxml-ruby', '=0.9.7' + # XmlMini.backend = 'LibXML' + module XmlMini + extend self + + # This module decorates files deserialized using Hash.from_xml with + # the <tt>original_filename</tt> and <tt>content_type</tt> methods. + module FileLike #:nodoc: + attr_writer :original_filename, :content_type + + def original_filename + @original_filename || "untitled" + end + + def content_type + @content_type || "application/octet-stream" + end + end + + DEFAULT_ENCODINGS = { + "binary" => "base64" + } unless defined?(DEFAULT_ENCODINGS) + + unless defined?(TYPE_NAMES) + TYPE_NAMES = { + "Symbol" => "symbol", + "Integer" => "integer", + "BigDecimal" => "decimal", + "Float" => "float", + "TrueClass" => "boolean", + "FalseClass" => "boolean", + "Date" => "date", + "DateTime" => "dateTime", + "Time" => "dateTime", + "Array" => "array", + "Hash" => "hash" + } + end + + FORMATTING = { + "symbol" => Proc.new { |symbol| symbol.to_s }, + "date" => Proc.new { |date| date.to_s(:db) }, + "dateTime" => Proc.new { |time| time.xmlschema }, + "binary" => Proc.new { |binary| ::Base64.encode64(binary) }, + "yaml" => Proc.new { |yaml| yaml.to_yaml } + } unless defined?(FORMATTING) + + # TODO use regexp instead of Date.parse + unless defined?(PARSING) + PARSING = { + "symbol" => Proc.new { |symbol| symbol.to_s.to_sym }, + "date" => Proc.new { |date| ::Date.parse(date) }, + "datetime" => Proc.new { |time| Time.xmlschema(time).utc rescue ::DateTime.parse(time).utc }, + "integer" => Proc.new { |integer| integer.to_i }, + "float" => Proc.new { |float| float.to_f }, + "decimal" => Proc.new do |number| + if String === number + number.to_d + else + BigDecimal(number) + end + end, + "boolean" => Proc.new { |boolean| %w(1 true).include?(boolean.to_s.strip) }, + "string" => Proc.new { |string| string.to_s }, + "yaml" => Proc.new { |yaml| YAML.load(yaml) rescue yaml }, + "base64Binary" => Proc.new { |bin| ::Base64.decode64(bin) }, + "binary" => Proc.new { |bin, entity| _parse_binary(bin, entity) }, + "file" => Proc.new { |file, entity| _parse_file(file, entity) } + } + + PARSING.update( + "double" => PARSING["float"], + "dateTime" => PARSING["datetime"] + ) + end + + attr_accessor :depth + self.depth = 100 + + delegate :parse, to: :backend + + def backend + current_thread_backend || @backend + end + + def backend=(name) + backend = name && cast_backend_name_to_module(name) + self.current_thread_backend = backend if current_thread_backend + @backend = backend + end + + def with_backend(name) + old_backend = current_thread_backend + self.current_thread_backend = name && cast_backend_name_to_module(name) + yield + ensure + self.current_thread_backend = old_backend + end + + def to_tag(key, value, options) + type_name = options.delete(:type) + merged_options = options.merge(root: key, skip_instruct: true) + + if value.is_a?(::Method) || value.is_a?(::Proc) + if value.arity == 1 + value.call(merged_options) + else + value.call(merged_options, key.to_s.singularize) + end + elsif value.respond_to?(:to_xml) + value.to_xml(merged_options) + else + type_name ||= TYPE_NAMES[value.class.name] + type_name ||= value.class.name if value && !value.respond_to?(:to_str) + type_name = type_name.to_s if type_name + type_name = "dateTime" if type_name == "datetime" + + key = rename_key(key.to_s, options) + + attributes = options[:skip_types] || type_name.nil? ? {} : { type: type_name } + attributes[:nil] = true if value.nil? + + encoding = options[:encoding] || DEFAULT_ENCODINGS[type_name] + attributes[:encoding] = encoding if encoding + + formatted_value = FORMATTING[type_name] && !value.nil? ? + FORMATTING[type_name].call(value) : value + + options[:builder].tag!(key, formatted_value, attributes) + end + end + + def rename_key(key, options = {}) + camelize = options[:camelize] + dasherize = !options.has_key?(:dasherize) || options[:dasherize] + if camelize + key = true == camelize ? key.camelize : key.camelize(camelize) + end + key = _dasherize(key) if dasherize + key + end + + private + + def _dasherize(key) + # $2 must be a non-greedy regex for this to work + left, middle, right = /\A(_*)(.*?)(_*)\Z/.match(key.strip)[1, 3] + "#{left}#{middle.tr('_ ', '--')}#{right}" + end + + # TODO: Add support for other encodings + def _parse_binary(bin, entity) + case entity["encoding"] + when "base64" + ::Base64.decode64(bin) + else + bin + end + end + + def _parse_file(file, entity) + f = StringIO.new(::Base64.decode64(file)) + f.extend(FileLike) + f.original_filename = entity["name"] + f.content_type = entity["content_type"] + f + end + + def current_thread_backend + Thread.current[:xml_mini_backend] + end + + def current_thread_backend=(name) + Thread.current[:xml_mini_backend] = name && cast_backend_name_to_module(name) + end + + def cast_backend_name_to_module(name) + if name.is_a?(Module) + name + else + require "active_support/xml_mini/#{name.downcase}" + ActiveSupport.const_get("XmlMini_#{name}") + end + end + end + + XmlMini.backend = "REXML" +end diff --git a/activesupport/lib/active_support/xml_mini/jdom.rb b/activesupport/lib/active_support/xml_mini/jdom.rb new file mode 100644 index 0000000000..32fe6ade28 --- /dev/null +++ b/activesupport/lib/active_support/xml_mini/jdom.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +raise "JRuby is required to use the JDOM backend for XmlMini" unless RUBY_PLATFORM.include?("java") + +require "jruby" +include Java + +require "active_support/core_ext/object/blank" + +java_import javax.xml.parsers.DocumentBuilder unless defined? DocumentBuilder +java_import javax.xml.parsers.DocumentBuilderFactory unless defined? DocumentBuilderFactory +java_import java.io.StringReader unless defined? StringReader +java_import org.xml.sax.InputSource unless defined? InputSource +java_import org.xml.sax.Attributes unless defined? Attributes +java_import org.w3c.dom.Node unless defined? Node + +module ActiveSupport + module XmlMini_JDOM #:nodoc: + extend self + + CONTENT_KEY = "__content__" + + NODE_TYPE_NAMES = %w{ATTRIBUTE_NODE CDATA_SECTION_NODE COMMENT_NODE DOCUMENT_FRAGMENT_NODE + DOCUMENT_NODE DOCUMENT_TYPE_NODE ELEMENT_NODE ENTITY_NODE ENTITY_REFERENCE_NODE NOTATION_NODE + PROCESSING_INSTRUCTION_NODE TEXT_NODE} + + node_type_map = {} + NODE_TYPE_NAMES.each { |type| node_type_map[Node.send(type)] = type } + + # Parse an XML Document string or IO into a simple hash using Java's jdom. + # data:: + # XML Document string or IO to parse + def parse(data) + if data.respond_to?(:read) + data = data.read + end + + if data.blank? + {} + else + @dbf = DocumentBuilderFactory.new_instance + # secure processing of java xml + # https://archive.is/9xcQQ + @dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + @dbf.setFeature("http://xml.org/sax/features/external-general-entities", false) + @dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false) + @dbf.setFeature(javax.xml.XMLConstants::FEATURE_SECURE_PROCESSING, true) + xml_string_reader = StringReader.new(data) + xml_input_source = InputSource.new(xml_string_reader) + doc = @dbf.new_document_builder.parse(xml_input_source) + merge_element!({ CONTENT_KEY => "" }, doc.document_element, XmlMini.depth) + end + end + + private + + # Convert an XML element and merge into the hash + # + # hash:: + # Hash to merge the converted element into. + # element:: + # XML element to merge into hash + def merge_element!(hash, element, depth) + raise "Document too deep!" if depth == 0 + delete_empty(hash) + merge!(hash, element.tag_name, collapse(element, depth)) + end + + def delete_empty(hash) + hash.delete(CONTENT_KEY) if hash[CONTENT_KEY] == "" + end + + # Actually converts an XML document element into a data structure. + # + # element:: + # The document element to be collapsed. + def collapse(element, depth) + hash = get_attributes(element) + + child_nodes = element.child_nodes + if child_nodes.length > 0 + (0...child_nodes.length).each do |i| + child = child_nodes.item(i) + merge_element!(hash, child, depth - 1) unless child.node_type == Node.TEXT_NODE + end + merge_texts!(hash, element) unless empty_content?(element) + hash + else + merge_texts!(hash, element) + end + end + + # Merge all the texts of an element into the hash + # + # hash:: + # Hash to add the converted element to. + # element:: + # XML element whose texts are to me merged into the hash + def merge_texts!(hash, element) + delete_empty(hash) + text_children = texts(element) + if text_children.join.empty? + hash + else + # must use value to prevent double-escaping + merge!(hash, CONTENT_KEY, text_children.join) + end + end + + # Adds a new key/value pair to an existing Hash. If the key to be added + # already exists and the existing value associated with key is not + # an Array, it will be wrapped in an Array. Then the new value is + # appended to that Array. + # + # hash:: + # Hash to add key/value pair to. + # key:: + # Key to be added. + # value:: + # Value to be associated with key. + def merge!(hash, key, value) + if hash.has_key?(key) + if hash[key].instance_of?(Array) + hash[key] << value + else + hash[key] = [hash[key], value] + end + elsif value.instance_of?(Array) + hash[key] = [value] + else + hash[key] = value + end + hash + end + + # Converts the attributes array of an XML element into a hash. + # Returns an empty Hash if node has no attributes. + # + # element:: + # XML element to extract attributes from. + def get_attributes(element) + attribute_hash = {} + attributes = element.attributes + (0...attributes.length).each do |i| + attribute_hash[CONTENT_KEY] ||= "" + attribute_hash[attributes.item(i).name] = attributes.item(i).value + end + attribute_hash + end + + # Determines if a document element has text content + # + # element:: + # XML element to be checked. + def texts(element) + texts = [] + child_nodes = element.child_nodes + (0...child_nodes.length).each do |i| + item = child_nodes.item(i) + if item.node_type == Node.TEXT_NODE + texts << item.get_data + end + end + texts + end + + # Determines if a document element has text content + # + # element:: + # XML element to be checked. + def empty_content?(element) + text = +"" + child_nodes = element.child_nodes + (0...child_nodes.length).each do |i| + item = child_nodes.item(i) + if item.node_type == Node.TEXT_NODE + text << item.get_data.strip + end + end + text.strip.length == 0 + end + end +end diff --git a/activesupport/lib/active_support/xml_mini/libxml.rb b/activesupport/lib/active_support/xml_mini/libxml.rb new file mode 100644 index 0000000000..c2e999ef6c --- /dev/null +++ b/activesupport/lib/active_support/xml_mini/libxml.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "libxml" +require "active_support/core_ext/object/blank" +require "stringio" + +module ActiveSupport + module XmlMini_LibXML #:nodoc: + extend self + + # Parse an XML Document string or IO into a simple hash using libxml. + # data:: + # XML Document string or IO to parse + def parse(data) + if !data.respond_to?(:read) + data = StringIO.new(data || "") + end + + if data.eof? + {} + else + LibXML::XML::Parser.io(data).parse.to_hash + end + end + end +end + +module LibXML #:nodoc: + module Conversions #:nodoc: + module Document #:nodoc: + def to_hash + root.to_hash + end + end + + module Node #:nodoc: + CONTENT_ROOT = "__content__" + + # Convert XML document to hash. + # + # hash:: + # Hash to merge the converted element into. + def to_hash(hash = {}) + node_hash = {} + + # Insert node hash into parent hash correctly. + case hash[name] + when Array then hash[name] << node_hash + when Hash then hash[name] = [hash[name], node_hash] + when nil then hash[name] = node_hash + end + + # Handle child elements + each_child do |c| + if c.element? + c.to_hash(node_hash) + elsif c.text? || c.cdata? + node_hash[CONTENT_ROOT] ||= +"" + node_hash[CONTENT_ROOT] << c.content + end + end + + # Remove content node if it is blank + if node_hash.length > 1 && node_hash[CONTENT_ROOT].blank? + node_hash.delete(CONTENT_ROOT) + end + + # Handle attributes + each_attr { |a| node_hash[a.name] = a.value } + + hash + end + end + end +end + +# :enddoc: + +LibXML::XML::Document.include(LibXML::Conversions::Document) +LibXML::XML::Node.include(LibXML::Conversions::Node) diff --git a/activesupport/lib/active_support/xml_mini/libxmlsax.rb b/activesupport/lib/active_support/xml_mini/libxmlsax.rb new file mode 100644 index 0000000000..ac8acdfc3c --- /dev/null +++ b/activesupport/lib/active_support/xml_mini/libxmlsax.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "libxml" +require "active_support/core_ext/object/blank" +require "stringio" + +module ActiveSupport + module XmlMini_LibXMLSAX #:nodoc: + extend self + + # Class that will build the hash while the XML document + # is being parsed using SAX events. + class HashBuilder + include LibXML::XML::SaxParser::Callbacks + + CONTENT_KEY = "__content__" + HASH_SIZE_KEY = "__hash_size__" + + attr_reader :hash + + def current_hash + @hash_stack.last + end + + def on_start_document + @hash = { CONTENT_KEY => +"" } + @hash_stack = [@hash] + end + + def on_end_document + @hash = @hash_stack.pop + @hash.delete(CONTENT_KEY) + end + + def on_start_element(name, attrs = {}) + new_hash = { CONTENT_KEY => +"" }.merge!(attrs) + new_hash[HASH_SIZE_KEY] = new_hash.size + 1 + + case current_hash[name] + when Array then current_hash[name] << new_hash + when Hash then current_hash[name] = [current_hash[name], new_hash] + when nil then current_hash[name] = new_hash + end + + @hash_stack.push(new_hash) + end + + def on_end_element(name) + if current_hash.length > current_hash.delete(HASH_SIZE_KEY) && current_hash[CONTENT_KEY].blank? || current_hash[CONTENT_KEY] == "" + current_hash.delete(CONTENT_KEY) + end + @hash_stack.pop + end + + def on_characters(string) + current_hash[CONTENT_KEY] << string + end + + alias_method :on_cdata_block, :on_characters + end + + attr_accessor :document_class + self.document_class = HashBuilder + + def parse(data) + if !data.respond_to?(:read) + data = StringIO.new(data || "") + end + + if data.eof? + {} + else + LibXML::XML::Error.set_handler(&LibXML::XML::Error::QUIET_HANDLER) + parser = LibXML::XML::SaxParser.io(data) + document = document_class.new + + parser.callbacks = document + parser.parse + document.hash + end + end + end +end diff --git a/activesupport/lib/active_support/xml_mini/nokogiri.rb b/activesupport/lib/active_support/xml_mini/nokogiri.rb new file mode 100644 index 0000000000..f76513f48b --- /dev/null +++ b/activesupport/lib/active_support/xml_mini/nokogiri.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +begin + require "nokogiri" +rescue LoadError => e + $stderr.puts "You don't have nokogiri installed in your application. Please add it to your Gemfile and run bundle install" + raise e +end +require "active_support/core_ext/object/blank" +require "stringio" + +module ActiveSupport + module XmlMini_Nokogiri #:nodoc: + extend self + + # Parse an XML Document string or IO into a simple hash using libxml / nokogiri. + # data:: + # XML Document string or IO to parse + def parse(data) + if !data.respond_to?(:read) + data = StringIO.new(data || "") + end + + if data.eof? + {} + else + doc = Nokogiri::XML(data) + raise doc.errors.first if doc.errors.length > 0 + doc.to_hash + end + end + + module Conversions #:nodoc: + module Document #:nodoc: + def to_hash + root.to_hash + end + end + + module Node #:nodoc: + CONTENT_ROOT = "__content__" + + # Convert XML document to hash. + # + # hash:: + # Hash to merge the converted element into. + def to_hash(hash = {}) + node_hash = {} + + # Insert node hash into parent hash correctly. + case hash[name] + when Array then hash[name] << node_hash + when Hash then hash[name] = [hash[name], node_hash] + when nil then hash[name] = node_hash + end + + # Handle child elements + children.each do |c| + if c.element? + c.to_hash(node_hash) + elsif c.text? || c.cdata? + node_hash[CONTENT_ROOT] ||= +"" + node_hash[CONTENT_ROOT] << c.content + end + end + + # Remove content node if it is blank and there are child tags + if node_hash.length > 1 && node_hash[CONTENT_ROOT].blank? + node_hash.delete(CONTENT_ROOT) + end + + # Handle attributes + attribute_nodes.each { |a| node_hash[a.node_name] = a.value } + + hash + end + end + end + + Nokogiri::XML::Document.include(Conversions::Document) + Nokogiri::XML::Node.include(Conversions::Node) + end +end diff --git a/activesupport/lib/active_support/xml_mini/nokogirisax.rb b/activesupport/lib/active_support/xml_mini/nokogirisax.rb new file mode 100644 index 0000000000..55cd72e093 --- /dev/null +++ b/activesupport/lib/active_support/xml_mini/nokogirisax.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +begin + require "nokogiri" +rescue LoadError => e + $stderr.puts "You don't have nokogiri installed in your application. Please add it to your Gemfile and run bundle install" + raise e +end +require "active_support/core_ext/object/blank" +require "stringio" + +module ActiveSupport + module XmlMini_NokogiriSAX #:nodoc: + extend self + + # Class that will build the hash while the XML document + # is being parsed using SAX events. + class HashBuilder < Nokogiri::XML::SAX::Document + CONTENT_KEY = "__content__" + HASH_SIZE_KEY = "__hash_size__" + + attr_reader :hash + + def current_hash + @hash_stack.last + end + + def start_document + @hash = {} + @hash_stack = [@hash] + end + + def end_document + raise "Parse stack not empty!" if @hash_stack.size > 1 + end + + def error(error_message) + raise error_message + end + + def start_element(name, attrs = []) + new_hash = { CONTENT_KEY => +"" }.merge!(Hash[attrs]) + new_hash[HASH_SIZE_KEY] = new_hash.size + 1 + + case current_hash[name] + when Array then current_hash[name] << new_hash + when Hash then current_hash[name] = [current_hash[name], new_hash] + when nil then current_hash[name] = new_hash + end + + @hash_stack.push(new_hash) + end + + def end_element(name) + if current_hash.length > current_hash.delete(HASH_SIZE_KEY) && current_hash[CONTENT_KEY].blank? || current_hash[CONTENT_KEY] == "" + current_hash.delete(CONTENT_KEY) + end + @hash_stack.pop + end + + def characters(string) + current_hash[CONTENT_KEY] << string + end + + alias_method :cdata_block, :characters + end + + attr_accessor :document_class + self.document_class = HashBuilder + + def parse(data) + if !data.respond_to?(:read) + data = StringIO.new(data || "") + end + + if data.eof? + {} + else + document = document_class.new + parser = Nokogiri::XML::SAX::Parser.new(document) + parser.parse(data) + document.hash + end + end + end +end diff --git a/activesupport/lib/active_support/xml_mini/rexml.rb b/activesupport/lib/active_support/xml_mini/rexml.rb new file mode 100644 index 0000000000..8d6e3af066 --- /dev/null +++ b/activesupport/lib/active_support/xml_mini/rexml.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "active_support/core_ext/kernel/reporting" +require "active_support/core_ext/object/blank" +require "stringio" + +module ActiveSupport + module XmlMini_REXML #:nodoc: + extend self + + CONTENT_KEY = "__content__" + + # Parse an XML Document string or IO into a simple hash. + # + # Same as XmlSimple::xml_in but doesn't shoot itself in the foot, + # and uses the defaults from Active Support. + # + # data:: + # XML Document string or IO to parse + def parse(data) + if !data.respond_to?(:read) + data = StringIO.new(data || "") + end + + if data.eof? + {} + else + silence_warnings { require "rexml/document" } unless defined?(REXML::Document) + doc = REXML::Document.new(data) + + if doc.root + merge_element!({}, doc.root, XmlMini.depth) + else + raise REXML::ParseException, + "The document #{doc.to_s.inspect} does not have a valid root" + end + end + end + + private + # Convert an XML element and merge into the hash + # + # hash:: + # Hash to merge the converted element into. + # element:: + # XML element to merge into hash + def merge_element!(hash, element, depth) + raise REXML::ParseException, "The document is too deep" if depth == 0 + merge!(hash, element.name, collapse(element, depth)) + end + + # Actually converts an XML document element into a data structure. + # + # element:: + # The document element to be collapsed. + def collapse(element, depth) + hash = get_attributes(element) + + if element.has_elements? + element.each_element { |child| merge_element!(hash, child, depth - 1) } + merge_texts!(hash, element) unless empty_content?(element) + hash + else + merge_texts!(hash, element) + end + end + + # Merge all the texts of an element into the hash + # + # hash:: + # Hash to add the converted element to. + # element:: + # XML element whose texts are to me merged into the hash + def merge_texts!(hash, element) + unless element.has_text? + hash + else + # must use value to prevent double-escaping + texts = +"" + element.texts.each { |t| texts << t.value } + merge!(hash, CONTENT_KEY, texts) + end + end + + # Adds a new key/value pair to an existing Hash. If the key to be added + # already exists and the existing value associated with key is not + # an Array, it will be wrapped in an Array. Then the new value is + # appended to that Array. + # + # hash:: + # Hash to add key/value pair to. + # key:: + # Key to be added. + # value:: + # Value to be associated with key. + def merge!(hash, key, value) + if hash.has_key?(key) + if hash[key].instance_of?(Array) + hash[key] << value + else + hash[key] = [hash[key], value] + end + elsif value.instance_of?(Array) + hash[key] = [value] + else + hash[key] = value + end + hash + end + + # Converts the attributes array of an XML element into a hash. + # Returns an empty Hash if node has no attributes. + # + # element:: + # XML element to extract attributes from. + def get_attributes(element) + attributes = {} + element.attributes.each { |n, v| attributes[n] = v } + attributes + end + + # Determines if a document element has text content + # + # element:: + # XML element to be checked. + def empty_content?(element) + element.texts.join.blank? + end + end +end |