Skip to content

Commit

Permalink
Improve support for custom namespaces
Browse files Browse the repository at this point in the history
  • Loading branch information
fxn committed Mar 5, 2023
1 parent cc0951d commit 87f3f81
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 0 deletions.
36 changes: 36 additions & 0 deletions guides/source/autoloading_and_reloading_constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------------------

Expand Down
25 changes: 25 additions & 0 deletions railties/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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*
Expand Down
6 changes: 6 additions & 0 deletions railties/lib/rails/application/bootstrap.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -79,10 +80,15 @@ module Bootstrap
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)
Expand Down
6 changes: 6 additions & 0 deletions railties/lib/rails/application/finisher.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -17,10 +18,15 @@ module Finisher
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)
Expand Down
42 changes: 42 additions & 0 deletions railties/test/application/zeitwerk_integration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@ class RESTfulController < ApplicationController
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"
Expand Down

0 comments on commit 87f3f81

Please sign in to comment.