Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 19 additions & 47 deletions lib/faker.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require_relative 'faker/loader'

mydir = __dir__

require 'psych'
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
84 changes: 84 additions & 0 deletions lib/faker/loader.rb
Original file line number Diff line number Diff line change
@@ -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

@thdaraujo thdaraujo Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this might cause a deadlock? maybe a Monitor is a better fit?
https://ruby-doc.org/stdlib-2.5.3/libdoc/monitor/rdoc/Monitor.html

if loading_strategy == :lazy
resolve_const(context_name, class_name)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is currently broken on lazy loading

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
160 changes: 160 additions & 0 deletions test/faker/test_loader.rb
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stub Kernel#require to spy on files being required/loaded

@thdaraujo thdaraujo Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could pass require or Kernel via dependency injection to make it easier to test, but doesn't seem worth it.

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>.*)/)[: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>.*)/)[: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
10 changes: 10 additions & 0 deletions test/fixtures/faker/default/widget.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions test/fixtures/faker/gadget.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Faker
# rubocop:disable Lint/EmptyClass
class Gadget
end
# rubocop:enable Lint/EmptyClass
end
10 changes: 10 additions & 0 deletions test/fixtures/faker/games/dnd.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions test/test_determinism.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# rubocop:disable Security/Eval,Style/EvalWithLocation
class TestDeterminism < Test::Unit::TestCase
def setup
# TODO: can we expose loader?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe add a Faker::Config.eager_load! method

Faker.instance_variable_get(:@loader).send(:eager_load!)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬


@all_methods = all_methods.freeze
@first_run = []
end
Expand All @@ -14,7 +17,7 @@
Faker::Config.random = Random.new(42)

@all_methods.each_index do |index|
store_result @all_methods[index]

Check failure on line 20 in test/test_determinism.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.2 with lazy loading

Error

RuntimeError: Faker::Blockchain.const_missing raised "wrong number of arguments (given 0, expected 1)" /home/runner/work/faker/faker/test/test_determinism.rb:64:in `rescue in store_result' /home/runner/work/faker/faker/test/test_determinism.rb:61:in `store_result' /home/runner/work/faker/faker/test/test_determinism.rb:20:in `block in test_determinism' /home/runner/work/faker/faker/test/test_determinism.rb:19:in `each_index' /home/runner/work/faker/faker/test/test_determinism.rb:19:in `test_determinism'
end

@first_run.freeze
Expand Down Expand Up @@ -94,6 +97,7 @@
Time
TvShows
Music
Loader
VERSION
]
end
Expand Down
Loading