diff --git a/lib/mongoid/loadable.rb b/lib/mongoid/loadable.rb index bb3f6d8205..63bde8edaf 100644 --- a/lib/mongoid/loadable.rb +++ b/lib/mongoid/loadable.rb @@ -10,6 +10,13 @@ module Loadable # (See #model_paths.) DEFAULT_MODEL_PATHS = %w( ./app/models ./lib/models ).freeze + # The default list of glob patterns that match paths to ignore when loading + # models. Defaults to '*/models/concerns/*', which Rails uses for extensions + # to models (and which cause errors when loaded out of order). + # + # See #ignore_patterns. + DEFAULT_IGNORE_PATTERNS = %w( */models/concerns/* ).freeze + # Search a list of model paths to get every model and require it, so # that indexing and inheritance work in both development and production # with the same results. @@ -24,17 +31,47 @@ module Loadable # for model files. These must either be absolute paths, or relative to # the current working directory. def load_models(paths = model_paths) - paths.each do |path| - if preload_models.resizable? - files = preload_models.map { |model| "#{path}/#{model.underscore}.rb" } + files = files_under_paths(paths) + + files.sort.each do |file| + load_model(file) + end + + nil + end + + # Given a list of paths, return all ruby files under that path (or, if + # `preload_models` is a list of model names, returns only the files for + # those named models). + # + # @param [ Array ] paths the list of paths to search + # + # @return [ Array ] the normalized file names, suitable for loading + # via `require_dependency` or `require`. + def files_under_paths(paths) + paths.flat_map { |path| files_under_path(path) } + end + + # Given a single path, returns all ruby files under that path (or, if + # `preload_models` is a list of model names, returns only the files for + # those named models). + # + # @param [ String ] path the path to search + # + # @return [ Array ] the normalized file names, suitable for loading + # via `require_dependency` or `require`. + def files_under_path(path) + files = if preload_models.resizable? + preload_models. + map { |model| "#{path}/#{model.underscore}.rb" }. + select { |file_name| File.exists?(file_name) } else - files = Dir.glob("#{path}/**/*.rb") + Dir.glob("#{path}/**/*.rb"). + reject { |file_name| ignored?(file_name) } end - files.sort.each do |file| - load_model(file.gsub(/^#{path}\// , "").gsub(/\.rb$/, "")) - end - end + # strip the path and the suffix from each entry + files.map { |file| file.gsub(/^#{path}\// , "").gsub(/\.rb$/, "") } end # A convenience method for loading a model's file. If Rails' @@ -71,6 +108,14 @@ def model_paths DEFAULT_MODEL_PATHS end + # Returns the array of glob patterns that determine whether a given + # path should be ignored by the model loader. + # + # @return [ Array ] the array of ignore patterns + def ignore_patterns + @ignore_patterns ||= DEFAULT_IGNORE_PATTERNS.dup + end + # Sets the model paths to the given array of paths. These are the paths # where the application's model definitions are located. # @@ -78,6 +123,25 @@ def model_paths def model_paths=(paths) @model_paths = paths end + + # Sets the ignore patterns to the given array of patterns. These are glob + # patterns that determine whether a given path should be ignored by the + # model loader or not. + # + # @param [ Array ] patterns The list of glob patterns + def ignore_patterns=(patterns) + @ignore_patterns = patterns + end + + # Returns true if the given file path matches any of the ignore patterns. + # + # @param [ String ] file_path The file path to consider + # + # @return [ true | false ] whether or not the given file path should be + # ignored. + def ignored?(file_path) + ignore_patterns.any? { |pattern| File.fnmatch?(pattern, file_path) } + end end end diff --git a/spec/mongoid/loadable_spec.rb b/spec/mongoid/loadable_spec.rb new file mode 100644 index 0000000000..e0277dbfbb --- /dev/null +++ b/spec/mongoid/loadable_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mongoid::Loadable do + let(:lib_dir) { Pathname.new('../../lib').realpath(__dir__) } + + shared_context 'with ignore_patterns' do + around do |example| + saved = Mongoid.ignore_patterns + Mongoid.ignore_patterns = ignore_patterns + example.run + ensure + Mongoid.ignore_patterns = saved + end + end + + describe '#ignore_patterns' do + context 'when not explicitly set' do + it 'equals the default list of ignore patterns' do + expect(Mongoid.ignore_patterns).to eq Mongoid::Loadable::DEFAULT_IGNORE_PATTERNS + end + end + + context 'when explicitly set' do + include_context 'with ignore_patterns' + + let(:ignore_patterns) { %w[ pattern1 pattern2 ] } + + it 'equals the list of specified patterns' do + expect(Mongoid.ignore_patterns).to eq ignore_patterns + end + end + end + + describe '#files_under_path' do + let(:results) { Mongoid.files_under_path(lib_dir) } + + include_context 'with ignore_patterns' + + context 'when ignore_patterns is empty' do + let(:ignore_patterns) { [] } + + it 'returns all ruby files' do + expect(results.length).to be > 10 # should be a bunch of them + expect(results).to include('rails/mongoid') + end + end + + context 'when ignore_patterns is not empty' do + let(:ignore_patterns) { %w[ */rails/* ] } + + it 'omits the ignored paths' do + expect(results.length).to be > 10 # should be a bunch of them + expect(results).not_to include('rails/mongoid') + end + end + end + + describe '#files_under_paths' do + let(:paths) { [ lib_dir.join('mongoid'), lib_dir.join('rails') ] } + let(:results) { Mongoid.files_under_paths(paths) } + + include_context 'with ignore_patterns' + + context 'when ignore_patterns is empty' do + let(:ignore_patterns) { [] } + + it 'returns all ruby files' do + expect(results.length).to be > 10 # should be a bunch + expect(results).to include('generators/mongoid/model/model_generator') + expect(results).to include('fields/encrypted') + end + end + + context 'when ignore_patterns is not empty' do + let(:ignore_patterns) { %w[ */model/* */fields/* ] } + + it 'returns all ruby files' do + expect(results.length).to be > 10 # should be a bunch + expect(results).not_to include('generators/mongoid/model/model_generator') + expect(results).not_to include('fields/encrypted') + end + end + end +end