Skip to content

Commit daf9d88

Browse files
authored
Add Zeitwerk support (#85)
This PR adds support for using Zeitwerk which: - supports using collapsed directories - for Rails applications with Zeitwerk, should speed up annotation because it no longer eager loads
1 parent eb7bea8 commit daf9d88

File tree

13 files changed

+246
-4
lines changed

13 files changed

+246
-4
lines changed

lib/annotate_rb/eager_loader.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ def call(options)
88
options[:require].count > 0 && options[:require].each { |path| require path }
99

1010
if defined?(::Rails::Application)
11-
klass = ::Rails::Application.send(:subclasses).first
12-
klass.eager_load!
11+
if defined?(::Zeitwerk)
12+
# Delegate to Zeitwerk to load stuff as needed
13+
else
14+
klass = ::Rails::Application.send(:subclasses).first
15+
klass.eager_load!
16+
end
1317
else
1418
options[:model_dir].each do |dir|
1519
::Rake::FileList["#{dir}/**/*.rb"].each do |fname|

lib/annotate_rb/model_annotator.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ module ModelAnnotator
2525
autoload :ProjectAnnotationRemover, "annotate_rb/model_annotator/project_annotation_remover"
2626
autoload :AnnotatedFile, "annotate_rb/model_annotator/annotated_file"
2727
autoload :FileParser, "annotate_rb/model_annotator/file_parser"
28+
autoload :ZeitwerkClassGetter, "annotate_rb/model_annotator/zeitwerk_class_getter"
2829
end
2930
end

lib/annotate_rb/model_annotator/model_class_getter.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ class << self
88
# Check for namespaced models in subdirectories as well as models
99
# in subdirectories without namespacing.
1010
def call(file, options)
11+
use_zeitwerk = defined?(::Rails) && ::Rails.try(:autoloaders).try(:zeitwerk_enabled?)
12+
13+
if use_zeitwerk
14+
klass = ZeitwerkClassGetter.call(file, options)
15+
return klass if klass
16+
end
17+
1118
model_path = file.gsub(/\.rb$/, "")
1219
options[:model_dir].each { |dir| model_path = model_path.gsub(/^#{dir}/, "").gsub(/^\//, "") }
1320

lib/annotate_rb/model_annotator/model_wrapper.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,16 @@ def retrieve_indexes_from_table
115115

116116
# Try to search the table without prefix
117117
table_name_without_prefix = table_name.to_s.sub(@klass.table_name_prefix, "")
118-
@klass.connection.indexes(table_name_without_prefix)
118+
begin
119+
@klass.connection.indexes(table_name_without_prefix)
120+
rescue ActiveRecord::StatementInvalid => _e
121+
# Mysql2 adapter behaves differently than Sqlite3 and Postgres adapter.
122+
# If `table_name_without_prefix` does not exist, Mysql2 will raise,
123+
# the other adapters will return an empty array.
124+
#
125+
# See: https://github.com/rails/rails/issues/51205
126+
[]
127+
end
119128
end
120129

121130
def with_comments?
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# frozen_string_literal: true
2+
3+
module AnnotateRb
4+
module ModelAnnotator
5+
class ZeitwerkClassGetter
6+
class << self
7+
def call(file, options)
8+
new(file, options).call
9+
end
10+
end
11+
12+
def initialize(file, options)
13+
@file = file
14+
@options = options
15+
end
16+
17+
# @return [Constant, nil] Attempts to return the model class constant (e.g. User) defined in the model file
18+
# can return `nil` if the file does not define the constant.
19+
def call
20+
return unless defined?(::Zeitwerk)
21+
22+
@absolute_file_path = File.expand_path(@file)
23+
loader = ::Rails.autoloaders.main
24+
25+
if supports_cpath?
26+
constant_using_cpath(loader)
27+
else
28+
constant(loader)
29+
end
30+
end
31+
32+
private
33+
34+
def constant(loader)
35+
root_dirs = loader.dirs(namespaces: true) # or `root_dirs = loader.root_dirs` with zeitwerk < 2.6.1
36+
expanded_file = @absolute_file_path
37+
38+
# root_dir: "/home/dummyapp/app/models"
39+
root_dir, namespace = root_dirs.find do |dir, _namespace|
40+
expanded_file.start_with?(dir)
41+
end
42+
43+
# expanded_file: "/home/dummyapp/app/models/collapsed/example/test_model.rb"
44+
# filepath_relative_to_root_dir: "/collapsed/example/test_model.rb"
45+
_, filepath_relative_to_root_dir = expanded_file.split(root_dir)
46+
47+
# Remove leading / and the .rb extension
48+
filepath_relative_to_root_dir = filepath_relative_to_root_dir[1..].sub(/\.rb$/, "")
49+
50+
# once we have the filepath_relative_to_root_dir, we need to see if it
51+
# falls within one of our Zeitwerk "collapsed" paths.
52+
if loader.collapse.any? { |path| path.include?(root_dir) && file.include?(path.split(root_dir)[1]) }
53+
# if the file is within a collapsed path, we then need to, for each
54+
# collapsed path, remove the root dir
55+
collapsed = loader.collapse.map { |path| path.split(root_dir)[1].sub(/^\//, "") }.to_set
56+
57+
collapsed.each do |collapse|
58+
# next, we split the collapsed directory, e.g. `domain_name/models`, by
59+
# slash, and discard the domain_name
60+
_, *collapsed_namespace = collapse.split("/")
61+
62+
# if there are any collapsed namespaces, e.g. `models`, we then remove
63+
# that from `filepath_relative_to_root_dir`.
64+
#
65+
# This would result in:
66+
#
67+
# previous filepath_relative_to_root_dir: domain_name/models/model_name
68+
# new filepath_relative_to_root_dir: domain_name/model_name
69+
if collapsed_namespace.any?
70+
filepath_relative_to_root_dir.sub!("/#{collapsed_namespace.last}", "")
71+
end
72+
end
73+
end
74+
75+
camelize = loader.inflector.camelize(filepath_relative_to_root_dir, nil)
76+
namespace.const_get(camelize)
77+
rescue NameError => e
78+
warn e
79+
nil
80+
end
81+
82+
def constant_using_cpath(loader)
83+
begin
84+
constant = loader.cpath_expected_at(@absolute_file_path)
85+
rescue ::Zeitwerk::Error => e
86+
# Raises when file does not exist
87+
warn "Zeitwerk unable to find file #{@file}, error:\n#{e.message}"
88+
return
89+
end
90+
91+
begin
92+
# This uses ActiveSupport::Inflector.constantize
93+
klass = constant.constantize
94+
rescue NameError => e
95+
warn e
96+
return
97+
end
98+
99+
klass
100+
end
101+
102+
def supports_cpath?
103+
@supports_cpath ||=
104+
begin
105+
current_version = ::Gem::Version.new(::Zeitwerk::VERSION)
106+
required_version = ::Gem::Version.new("2.6.9")
107+
108+
current_version >= required_version
109+
end
110+
end
111+
end
112+
end
113+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module Collapsed
4+
class TestModel < ApplicationRecord
5+
def self.table_name_prefix
6+
"collapsed_"
7+
end
8+
end
9+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# frozen_string_literal: true
2+
3+
Rails.autoloaders.main.collapse("#{Rails.root}/app/models/collapsed/example")
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class CreateCollapsedTestModels < ActiveRecord::Migration[7.0]
2+
def change
3+
create_table :collapsed_test_models do |t|
4+
t.string :name
5+
t.boolean :collapsed
6+
7+
t.timestamps
8+
end
9+
end
10+
end

spec/integration/annotate_after_migration_spec.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222

2323
copy(File.join(migrations_template_dir, migration_file), "db/migrate")
2424

25-
_run_migrations_cmd = run_command_and_stop("bin/rails db:migrate VERSION=20231013230731", fail_on_error: true, exit_timeout: command_timeout_seconds)
25+
# Apply this specific migration
26+
_run_migrations_cmd = run_command_and_stop("bin/rails db:migrate:up VERSION=20231013230731", fail_on_error: true, exit_timeout: command_timeout_seconds)
2627
_run_annotations_cmd = run_command_and_stop("bundle exec annotaterb models", fail_on_error: true, exit_timeout: command_timeout_seconds)
2728

2829
annotated_test_default = read_file(dummyapp_model("test_default.rb"))
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
require "integration_spec_helper"
4+
5+
RSpec.describe "Annotate collapsed models", type: "aruba" do
6+
let(:models_dir) { "app/models" }
7+
let(:command_timeout_seconds) { 10 }
8+
9+
context "when annotating collapsed models" do
10+
it "annotates them correctly" do
11+
reset_database
12+
run_migrations
13+
14+
expected_test_model = read_file(model_template("collapsed_test_model.rb"))
15+
16+
original_test_model = read_file(dummyapp_model("collapsed/example/test_model.rb"))
17+
18+
expect(expected_test_model).not_to eq(original_test_model)
19+
20+
_cmd = run_command_and_stop("bundle exec annotaterb models", fail_on_error: true, exit_timeout: command_timeout_seconds)
21+
22+
annotated_test_model = read_file(dummyapp_model("collapsed/example/test_model.rb"))
23+
24+
expect(last_command_started).to be_successfully_executed
25+
expect(expected_test_model).to eq(annotated_test_model)
26+
end
27+
end
28+
end

0 commit comments

Comments
 (0)