Improve support for custom namespaces

This commit is contained in:
Xavier Noria 2023-03-05 21:35:51 +01:00
parent cc0951dbb8
commit 87f3f811a7
5 changed files with 115 additions and 0 deletions

View File

@ -487,6 +487,42 @@ If an application does not use the `once` autoloader, the snippets above can go
Applications using the `once` autoloader have to move or load this configuration from the body of the application class in `config/application.rb`, because the `once` autoloader uses the inflector early in the boot process.
Custom Namespaces
-----------------
As we saw above, autoload paths represent the top-level namespace: `Object`.
Let's consider `app/services`, for example. This directory is not generated by default, but if it exists, Rails automatically adds it to the autoload paths.
By default, the file `app/services/users/signup.rb` is expected to define `Users::Signup`, but what if you prefer that entire subtree to be under a `Services` namespace? Well, with default settings, that can be accomplished by creating a subdirectory: `app/services/services`.
However, depending on your taste, that just might not feel right to you. You might prefer that `app/services/users/signup.rb` simply defines `Services::Users::Signup`.
Zeitwerk supports [custom root namespaces](https://github.com/fxn/zeitwerk#custom-root-namespaces) to address this use case, and you can customize the `main` autoloader to accomplish that:
```ruby
# config/initializers/autoloading.rb
# The namespace has to exist.
#
# In this example we define the module on the spot. Could also be created
# elsewhere and its definition loaded here with an ordinary `require`. In
# any case, `push_dir` expects a class or module object as second argument.
module Services; end
Rails.autoloaders.main.push_dir("#{Rails.root}/app/services", Services)
```
Applications running on Rails < 7.1 have to additionally delete the directory from `ActiveSupport::Dependencies.autoload_paths`. Just add this line to the same file:
```ruby
# For applications running on Rails < 7.1.
# The argument has to be a string.
ActiveSupport::Dependencies.autoload_paths("#{Rails.root}/app/services")
```
Custom namespaces are also supported for the `once` autoloader. However, since that one is set up earlier in the boot process, the configuration cannot be done in an application initializer. Instead, please put it in `config/application.rb`, for example.
Autoloading and Engines
-----------------------

View File

@ -1,3 +1,28 @@
* Autoloading setup honors root directories manually set by the user.
This is relevant for custom namespaces. For example, if you'd like classes
and modules under `app/services` to be defined in the `Services` namespace
without an extra `app/services/services` directory, this is now enough:
```ruby
# config/initializers/autoloading.rb
# The namespace has to exist.
#
# In this example we define the module on the spot. Could also be created
# elsewhere and its definition loaded here with an ordinary `require`. In
# any case, `push_dir` expects a class or module object as second argument.
module Services; end
Rails.autoloaders.main.push_dir("#{Rails.root}/app/services", Services)
```
Before this change, Rails would later override the configuration. You had to
delete `app/services` from `ActiveSupport::Dependencies.autoload_paths` as
well.
*Xavier Noria*
* Use infinitive form for all rails command descriptions verbs.
*Petrik de Heus*

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
require "fileutils"
require "set"
require "active_support/notifications"
require "active_support/dependencies"
require "active_support/descendants_tracker"
@ -79,10 +80,15 @@ module Rails
initializer :setup_once_autoloader, after: :set_eager_load_paths, before: :bootstrap_hook do
autoloader = Rails.autoloaders.once
# Normally empty, but if the user already defined some, we won't
# override them. Important if there are custom namespaces associated.
already_configured_dirs = Set.new(autoloader.dirs)
ActiveSupport::Dependencies.autoload_once_paths.freeze
ActiveSupport::Dependencies.autoload_once_paths.uniq.each do |path|
# Zeitwerk only accepts existing directories in `push_dir`.
next unless File.directory?(path)
next if already_configured_dirs.member?(path.to_s)
autoloader.push_dir(path)
autoloader.do_not_eager_load(path) unless ActiveSupport::Dependencies.eager_load?(path)

View File

@ -1,5 +1,6 @@
# frozen_string_literal: true
require "set"
require "active_support/core_ext/string/inflections"
require "active_support/core_ext/array/conversions"
require "active_support/descendants_tracker"
@ -17,10 +18,15 @@ module Rails
initializer :setup_main_autoloader do
autoloader = Rails.autoloaders.main
# Normally empty, but if the user already defined some, we won't
# override them. Important if there are custom namespaces associated.
already_configured_dirs = Set.new(autoloader.dirs)
ActiveSupport::Dependencies.autoload_paths.freeze
ActiveSupport::Dependencies.autoload_paths.uniq.each do |path|
# Zeitwerk only accepts existing directories in `push_dir`.
next unless File.directory?(path)
next if already_configured_dirs.member?(path.to_s)
autoloader.push_dir(path)
autoloader.do_not_eager_load(path) unless ActiveSupport::Dependencies.eager_load?(path)

View File

@ -56,6 +56,48 @@ class ZeitwerkIntegrationTest < ActiveSupport::TestCase
assert RESTfulController
end
test "root directories manually set by the user are honored (once)" do
app_file "extras1/x.rb", "ZeitwerkIntegrationTestExtras::X = true"
app_file "extras2/y.rb", "ZeitwerkIntegrationTestExtras::Y = true"
add_to_env_config "development", <<~'RUBY'
config.autoload_once_paths << "#{Rails.root}/extras1"
config.autoload_once_paths << Rails.root.join("extras2")
module ZeitwerkIntegrationTestExtras; end
autoloader = Rails.autoloaders.once
autoloader.push_dir("#{Rails.root}/extras1", namespace: ZeitwerkIntegrationTestExtras)
autoloader.push_dir("#{Rails.root}/extras2", namespace: ZeitwerkIntegrationTestExtras)
RUBY
boot
assert ZeitwerkIntegrationTestExtras::X
assert ZeitwerkIntegrationTestExtras::Y
end
test "root directories manually set by the user are honored (main)" do
app_file "app/services/x.rb", "ZeitwerkIntegrationTestServices::X = true"
app_file "extras/x.rb", "ZeitwerkIntegrationTestExtras::X = true"
app_file "config/initializers/namespaces.rb", <<~'RUBY'
module ZeitwerkIntegrationTestServices; end
module ZeitwerkIntegrationTestExtras; end
ActiveSupport::Dependencies.autoload_paths << Rails.root.join("extras")
Rails.autoloaders.main.tap do |main|
main.push_dir("#{Rails.root}/app/services", namespace: ZeitwerkIntegrationTestServices)
main.push_dir("#{Rails.root}/extras", namespace: ZeitwerkIntegrationTestExtras)
end
RUBY
boot
assert ZeitwerkIntegrationTestServices::X
assert ZeitwerkIntegrationTestExtras::X
end
test "the once autoloader can autoload from initializers" do
app_file "extras0/x.rb", "X = 0"