diff --git a/lib/faker.rb b/lib/faker.rb index 30a87addac..4981dc7acd 100644 --- a/lib/faker.rb +++ b/lib/faker.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'faker/loader' + mydir = __dir__ require 'psych' @@ -14,9 +16,10 @@ module Faker module Config @default_locale = nil + @lazy_loading = false class << self - attr_writer :default_locale + attr_writer :default_locale, :lazy_loading def locale=(new_locale) Thread.current[:faker_config_locale] = new_locale @@ -43,16 +46,12 @@ def random end def lazy_loading? - if ENV.key?('FAKER_LAZY_LOAD') && !ENV['FAKER_LAZY_LOAD'].nil? - %w[true TRUE 1].include?(ENV.fetch('FAKER_LAZY_LOAD', nil)) + if ENV.key?('FAKER_LAZY_LOAD') + %w[true TRUE 1].include?(ENV['FAKER_LAZY_LOAD']) else - Thread.current[:faker_lazy_loading] == true + @lazy_loading end end - - def lazy_loading=(value) - Thread.current[:faker_lazy_loading] = value - end end end @@ -291,46 +290,19 @@ def disable_enforce_available_locales end end - if Faker::Config.lazy_loading? - def self.load_path(*constants) - constants.map do |class_name| - class_name - .to_s - .gsub('::', '/') - .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') - .gsub(/([a-z\d])([A-Z])/, '\1_\2') - .tr('-', '_') - .downcase - end.join('/') - end - - def self.lazy_load(klass) - def klass.const_missing(class_name) - load_path = case class_name - when :DnD - Faker.load_path('faker/games/dnd') - else - Faker.load_path(name, class_name) - end - - begin - require(load_path) - rescue LoadError - require(load_path.gsub('faker/', 'faker/default/')) - end - - const_get(class_name) - end - end + @loader = Loader.new(__dir__, Config) - lazy_load(self) + # Resolves missing constants by either lazy or eager loading generator files, + # depending on +Config.lazy_loading?+ at the time of first use. + # + # The loading strategy is determined on the first access. Setting + # +Config.lazy_loading+ after any generator has been referenced has no effect. + def self.const_missing(class_name) + @loader.load_const(name, class_name) + @loader.fetch_const(self, name, class_name) end -end - -unless Faker::Config.lazy_loading? - rb_files = [] - rb_files << File.join(mydir, 'faker', '*.rb') - rb_files << File.join(mydir, 'faker', '/**/*.rb') - Dir.glob(rb_files).each { |file| require file } + def self.lazy_load(klass) + @loader.install_on(klass) + end end diff --git a/lib/faker/loader.rb b/lib/faker/loader.rb new file mode 100644 index 0000000000..8a039b087f --- /dev/null +++ b/lib/faker/loader.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Faker + class Loader + INFLECTIONS = { 'DnD' => 'dnd' }.freeze + + def initialize(base_dir, config, requirer: method(:require)) + @base_dir = base_dir + @config = config + @eager_loaded = false + @mutex = Mutex.new + @requirer = requirer + end + + def load_const(context_name, class_name) + @mutex.synchronize do + if loading_strategy == :lazy + resolve_const(context_name, class_name) + else + eager_load! + end + end + end + + def install_on(klass) + loader = self + + klass.define_singleton_method(:const_missing) do |class_name| + loader.resolve_const(name, class_name) + loader.fetch_const(self, name, class_name) + end + end + + def fetch_const(klass, context_name, class_name) + unless klass.const_defined?(class_name, false) + raise NameError, "uninitialized constant #{context_name}::#{class_name}" + end + + klass.const_get(class_name) + end + + def loading_strategy + @loading_strategy ||= if @config.lazy_loading? + :lazy + else + :eager + end + end + + def resolve_const(context_name, class_name) + load_path = build_path(context_name, class_name) + + @requirer.call(load_path) + rescue LoadError + # try to load default generators + @requirer.call load_path.gsub('faker/', 'faker/default/') + end + + private + + def eager_load! + return if @eager_loaded + + @eager_loaded = true + + parents = Dir.glob("#{@base_dir}/faker/*.rb") + nested = Dir.glob("#{@base_dir}/faker/**/*.rb") - parents + + (parents + nested).each { |f| @requirer.call(f) } + end + + def build_path(*constants) + constants.map do |c| + INFLECTIONS + .reduce(c.to_s) { |s, (word, replacement)| s.gsub(word, replacement) } + .gsub('::', '/') + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .tr('-', '_') + .downcase + end.join('/') + end + end +end diff --git a/test/faker/test_loader.rb b/test/faker/test_loader.rb new file mode 100644 index 0000000000..ec1fb4eb8d --- /dev/null +++ b/test/faker/test_loader.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require_relative '../test_helper' + +class TestLoader < Test::Unit::TestCase + FIXTURES_DIR = File.expand_path('../fixtures', __dir__) + LIB_DIR = File.expand_path('../../lib', __dir__) + + class FakeRequirer + attr_reader :loaded_files + + def initialize(failing_paths: []) + @failing_paths = failing_paths + @loaded_files = [] + @mutex = Mutex.new + end + + def call(path) + raise LoadError if @failing_paths.any? do |suffix| + path.end_with?(suffix) + end + + @mutex.synchronize { @loaded_files << path } + end + end + + FakeConfig = Struct.new(:lazy_loading?) + + def eager_loader(requirer:) + Faker::Loader.new(FIXTURES_DIR, FakeConfig.new(false), requirer: requirer) + end + + def lazy_loader(requirer:) + Faker::Loader.new(FIXTURES_DIR, FakeConfig.new(true), requirer: requirer) + end + + def test_strategy_does_not_change_after_first_use + config = FakeConfig.new(false) + loader = Faker::Loader.new(FIXTURES_DIR, config, requirer: FakeRequirer.new) + + loader.load_const('Faker', :Gadget) + config[:lazy_loading?] = true + + assert_equal :eager, loader.loading_strategy + end + + def test_falls_back_to_default_path_on_load_error + non_default_path = ['faker/gadget'] + requirer = FakeRequirer.new(failing_paths: non_default_path) + + loader = lazy_loader(requirer: requirer) + + loader.load_const('Faker', :Gadget) + + assert requirer.loaded_files.any? { |f| f.include?('faker/default/gadget') } + end + + def test_inflection_resolves_correctly + requirer = FakeRequirer.new + loader = lazy_loader(requirer: requirer) + + loader.load_const('Faker::Games', :DnD) + + assert_includes requirer.loaded_files.first, 'faker/games/dnd' + refute_includes requirer.loaded_files.first, 'dn_d' + end + + def test_install_on_installs_const_missing + requirer = FakeRequirer.new + loader = lazy_loader(requirer: requirer) + klass = Class.new + + loader.install_on(klass) + + assert_equal klass.singleton_class, klass.method(:const_missing).owner + end + + def test_eager_loads_all_files_on_first_const_access + requirer = FakeRequirer.new + loader = eager_loader(requirer: requirer) + + loader.load_const('Faker', :Gadget) + + actual_files = requirer.loaded_files.map do |loaded| + loaded.match(/fixtures\/(?.*)/)[:path] + end.uniq + + expected_files = %w[ + faker/gadget.rb + faker/default/widget.rb + faker/games/dnd.rb + ] + + expected_files.each do |file| + assert_includes actual_files, file, "expected #{file} to be loaded" + end + end + + def test_requires_namespace_parents_before_nested_generators + requirer = FakeRequirer.new + loader = Faker::Loader.new(LIB_DIR, FakeConfig.new(false), requirer: requirer) + + loader.load_const('Faker', :Name) + + parents, nested = requirer.loaded_files.partition do |file| + File.dirname(file).end_with?('/faker') + end + + assert_equal requirer.loaded_files, parents + nested + end + + def test_eager_loads_only_once + requirer = FakeRequirer.new + loader = eager_loader(requirer: requirer) + + loader.load_const('Faker', :Gadget) + + count = requirer.loaded_files.size + + loader.load_const('Faker', :Gadget) + + assert_equal count, requirer.loaded_files.size + end + + def test_lazy_loads_single_file_on_const_access + requirer = FakeRequirer.new + loader = lazy_loader(requirer: requirer) + + loader.load_const('Faker', :Gadget) + + assert_equal 1, requirer.loaded_files.size + assert_includes requirer.loaded_files.first, 'faker/gadget' + end + + def test_eager_loads_only_once_across_threads + requirer = FakeRequirer.new + loader = eager_loader(requirer: requirer) + + threads = 10.times.map do + Thread.new { loader.load_const('Faker', :Gadget) } + end + + threads.each(&:join) + + actual_files = requirer.loaded_files.map do |loaded| + loaded.match(/fixtures\/(?.*)/)[:path] + end.compact + + assert_equal actual_files.uniq, actual_files + end + + def test_raises_on_unknown_const + non_existent_paths = ['faker/non_existent', 'faker/default/non_existent'] + requirer = FakeRequirer.new(failing_paths: non_existent_paths) + + loader = lazy_loader(requirer: requirer) + + assert_raises(LoadError) { loader.load_const('Faker', :NonExistent) } + end +end diff --git a/test/fixtures/faker/default/widget.rb b/test/fixtures/faker/default/widget.rb new file mode 100644 index 0000000000..b94d0deed0 --- /dev/null +++ b/test/fixtures/faker/default/widget.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Faker + class Default + # rubocop:disable Lint/EmptyClass + class Widget + end + # rubocop:enable Lint/EmptyClass + end +end diff --git a/test/fixtures/faker/gadget.rb b/test/fixtures/faker/gadget.rb new file mode 100644 index 0000000000..fade2a51f0 --- /dev/null +++ b/test/fixtures/faker/gadget.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Faker + # rubocop:disable Lint/EmptyClass + class Gadget + end + # rubocop:enable Lint/EmptyClass +end diff --git a/test/fixtures/faker/games/dnd.rb b/test/fixtures/faker/games/dnd.rb new file mode 100644 index 0000000000..92cd42db50 --- /dev/null +++ b/test/fixtures/faker/games/dnd.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Faker + class Games + # rubocop:disable Lint/EmptyClass + class DnD + end + # rubocop:enable Lint/EmptyClass + end +end diff --git a/test/test_determinism.rb b/test/test_determinism.rb index f612248af0..6cd80b6b86 100644 --- a/test/test_determinism.rb +++ b/test/test_determinism.rb @@ -6,6 +6,9 @@ # rubocop:disable Security/Eval,Style/EvalWithLocation class TestDeterminism < Test::Unit::TestCase def setup + # TODO: can we expose loader? + Faker.instance_variable_get(:@loader).send(:eager_load!) + @all_methods = all_methods.freeze @first_run = [] end @@ -94,6 +97,7 @@ def skipped_classes Time TvShows Music + Loader VERSION ] end