Skip to content

Commit

Permalink
Reimplement enum inspector via spy
Browse files Browse the repository at this point in the history
So far, enum inspector has been implemented by static code analysis.
But it is a bit fragile and difficult to maintain.

This reimplements the inspector by spy technique.  The new inspector
collects the enums definitions on loading ActiveRecord models.

Note: The new enum inspector must be installed before ActiveRecord
models loading.
  • Loading branch information
tk0miya committed Feb 2, 2025
1 parent 6a9a066 commit 638605d
Show file tree
Hide file tree
Showing 8 changed files with 100 additions and 82 deletions.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,20 @@ Run the following command. It generates `lib/tasks/rbs.rake`.
$ bin/rails g rbs_rails:install
```

Then, the following three tasks are available.
Then, the following four tasks are available.

* `rbs_rails:prepare`: Install inspector modules for Active Record models. This task is required to run before loading Rails application.
* `rbs_rails:generate_rbs_for_models`: Generate RBS files for Active Record models
* `rbs_rails:generate_rbs_for_path_helpers`: Generate RBS files for path helpers
* `rbs_rails:all`: Execute all tasks of RBS Rails


If you invoke multiple tasks, please run `rbs_rails:prepare` first.

```console
$ bin/rails rbs_rails:prepare some_task another_task rbs_rails:generate_rbs_for_models
```

### Install RBS for `rails` gem

You need to install `rails` gem's RBS files. I highly recommend using `rbs collection`.
Expand Down
1 change: 1 addition & 0 deletions lib/rbs_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_relative "rbs_rails/version"
require_relative "rbs_rails/util"
require_relative 'rbs_rails/active_record'
require_relative 'rbs_rails/active_record/enum'
require_relative 'rbs_rails/path_helpers'
require_relative 'rbs_rails/dependency_builder'

Expand Down
72 changes: 6 additions & 66 deletions lib/rbs_rails/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -318,16 +318,9 @@ def authenticate_#{attribute}: (String) -> (#{klass_name} | false)
private def enum_instance_methods
# @type var methods: Array[String]
methods = []
enum_definitions.each do |hash|
hash.each do |name, values|
next if IGNORED_ENUM_KEYS.include?(name)

values.each do |label, value|
value_method_name = enum_method_name(hash, name, label)
methods << "def #{value_method_name}!: () -> bool"
methods << "def #{value_method_name}?: () -> bool"
end
end
klass.enum_definitions.each do |_, method_name|
methods << "def #{method_name}!: () -> bool"
methods << "def #{method_name}?: () -> bool"
end

methods.join("\n")
Expand All @@ -336,65 +329,12 @@ def authenticate_#{attribute}: (String) -> (#{klass_name} | false)
private def enum_scope_methods(singleton:)
# @type var methods: Array[String]
methods = []
enum_definitions.each do |hash|
hash.each do |name, values|
next if IGNORED_ENUM_KEYS.include?(name)

values.each do |label, value|
value_method_name = enum_method_name(hash, name, label)
methods << "def #{singleton ? 'self.' : ''}#{value_method_name}: () -> #{relation_class_name}"
end
end
klass.enum_definitions.each do |_, method_name|
methods << "def #{singleton ? 'self.' : ''}#{method_name}: () -> #{relation_class_name}"
end
methods.join("\n")
end

private def enum_definitions
@enum_definitions ||= build_enum_definitions
end

# We need static analysis to detect enum.
# ActiveRecord has `defined_enums` method,
# but it does not contain _prefix and _suffix information.
private def build_enum_definitions
ast = parse_model_file
return [] unless ast

traverse(ast).map do |node|
# @type block: nil | Hash[untyped, untyped]
next unless node.type == :send
next unless node.children[0].nil?
next unless node.children[1] == :enum

definitions = node.children[2]
next unless definitions
next unless definitions.type == :hash
next unless traverse(definitions).all? { |n| [:str, :sym, :int, :hash, :pair, :true, :false].include?(n.type) }

code = definitions.loc.expression.source
code = "{#{code}}" if code[0] != '{'
eval(code)
end.compact
end

private def enum_method_name(hash, name, label)
enum_prefix = hash[:_prefix]
enum_suffix = hash[:_suffix]

if enum_prefix == true
prefix = "#{name}_"
elsif enum_prefix
prefix = "#{enum_prefix}_"
end
if enum_suffix == true
suffix = "_#{name}"
elsif enum_suffix
suffix = "_#{enum_suffix}"
end

"#{prefix}#{label}#{suffix}"
end

private def scopes(singleton:)
ast = parse_model_file
return '' unless ast
Expand Down Expand Up @@ -495,7 +435,7 @@ def authenticate_#{attribute}: (String) -> (#{klass_name} | false)
private def columns
mod_sig = +"module GeneratedAttributeMethods\n"
mod_sig << klass.columns.map do |col|
class_name = if enum_definitions.any? { |hash| hash.key?(col.name) || hash.key?(col.name.to_sym) }
class_name = if klass.enum_definitions.any? { |name, _| name == col.name.to_sym }
'::String'
else
sql_type_to_class(col.type)
Expand Down
52 changes: 52 additions & 0 deletions lib/rbs_rails/active_record/enum.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require 'active_support/lazy_load_hooks'

module RbsRails
module ActiveRecord
module Enum
IGNORED_ENUM_KEYS = %i[_prefix _suffix _default _scopes]

def enum(*args, **options)
super # steep:ignore

if args.empty?
definitions = options.slice!(*IGNORED_ENUM_KEYS)
@enum_definitions ||= []
@enum_definitions&.append([definitions, options])
end
end

def enum_definitions
@enum_definitions&.flat_map do |(definitions, options)|
definitions.flat_map do |name, values|
values.map do |label, value|
[name, enum_method_name(name, label, options)]
end
end
end.to_a
end

private def enum_method_name(name, label, options)
enum_prefix = options[:_prefix]
enum_suffix = options[:_suffix]

if enum_prefix == true
prefix = "#{name}_"
elsif enum_prefix
prefix = "#{enum_prefix}_"
end
if enum_suffix == true
suffix = "_#{name}"
elsif enum_suffix
suffix = "_#{enum_suffix}"
end

"#{prefix}#{label}#{suffix}"
end
end
end
end

ActiveSupport.on_load(:active_record) do
# @type self: singleton(ActiveRecord::Base)
extend RbsRails::ActiveRecord::Enum
end
19 changes: 15 additions & 4 deletions lib/rbs_rails/rake_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def initialize(name = :rbs_rails, &block)

block.call(self) if block

def_prepare
def_generate_rbs_for_models
def_generate_rbs_for_path_helpers
def_all
Expand All @@ -22,19 +23,29 @@ def initialize(name = :rbs_rails, &block)
def def_all
desc 'Run all tasks of rbs_rails'

deps = [:"#{name}:generate_rbs_for_models", :"#{name}:generate_rbs_for_path_helpers"]
deps = [:"#{name}:prepare",
:"#{name}:generate_rbs_for_models",
:"#{name}:generate_rbs_for_path_helpers"]
task("#{name}:all": deps)
end

def def_prepare
desc 'Prepare rbs_rails'
task "#{name}:prepare" do
# Load inspectors. This is necessary to load earlier than Rails application.
require 'rbs_rails/active_record/enum'
end
end

def def_generate_rbs_for_models
desc 'Generate RBS files for Active Record models'
task("#{name}:generate_rbs_for_models": :environment) do
task("#{name}:generate_rbs_for_models": [:"#{name}:prepare", :environment]) do
require 'rbs_rails'

Rails.application.eager_load!

dep_builder = DependencyBuilder.new

::ActiveRecord::Base.descendants.each do |klass|
next unless RbsRails::ActiveRecord.generatable?(klass)
next if ignore_model_if&.call(klass)
Expand All @@ -55,7 +66,7 @@ def def_generate_rbs_for_models

def def_generate_rbs_for_path_helpers
desc 'Generate RBS files for path helpers'
task("#{name}:generate_rbs_for_path_helpers": :environment) do
task("#{name}:generate_rbs_for_path_helpers": [:"#{name}:prepare", :environment]) do
require 'rbs_rails'

out_path = signature_root_dir.join 'path_helpers.rbs'
Expand Down
13 changes: 2 additions & 11 deletions sig/rbs_rails/active_record.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class RbsRails::ActiveRecord::Generator

IGNORED_ENUM_KEYS: Array[Symbol]

def initialize: (singleton(ActiveRecord::Base) klass, dependencies: Array[String]) -> untyped
def initialize: (singleton(ActiveRecord::Base) & RbsRails::ActiveRecord::Enum klass, dependencies: Array[String]) -> untyped

def generate: () -> String

Expand Down Expand Up @@ -51,15 +51,6 @@ class RbsRails::ActiveRecord::Generator

def enum_scope_methods: (singleton: untyped `singleton`) -> String

def enum_definitions: () -> Array[Hash[Symbol, untyped]]

# We need static analysis to detect enum.
# ActiveRecord has `defined_enums` method,
# but it does not contain _prefix and _suffix information.
def build_enum_definitions: () -> Array[Hash[Symbol, untyped]]

def enum_method_name: (Hash[Symbol, untyped] hash, Symbol name, Symbol label) -> String

def scopes: (singleton: untyped `singleton`) -> untyped

def args_to_type: (untyped args_node) -> untyped
Expand All @@ -83,5 +74,5 @@ class RbsRails::ActiveRecord::Generator

private

attr_reader klass: singleton(ActiveRecord::Base)
attr_reader klass: singleton(ActiveRecord::Base) & RbsRails::ActiveRecord::Enum
end
14 changes: 14 additions & 0 deletions sig/rbs_rails/active_record/enum.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module RbsRails
module ActiveRecord
module Enum
IGNORED_ENUM_KEYS: Array[Symbol]

@enum_definitions: Array[[Hash[Symbol, untyped], Hash[Symbol, untyped]]]?

def enum: (*untyped, **untyped) -> void
def enum_definitions: () -> Array[[Symbol, String]]

private def enum_method_name: (Symbol name, Symbol label, Hash[Symbol, untyped] options) -> String
end
end
end
2 changes: 2 additions & 0 deletions sig/rbs_rails/rake_task.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ module RbsRails

def def_copy_signature_files: () -> void

def def_prepare: () -> void

def def_generate_rbs_for_models: () -> void

def def_generate_rbs_for_path_helpers: () -> void
Expand Down

0 comments on commit 638605d

Please sign in to comment.