ActiveModule: A Module attribute type implementation

I have just published a little gem that allows to store references to classes and modules in active record attributes. I am also thinking of contributing this code to rails-core. (I just need to port the tests from rspec and move the README into the rails docs)

Any comments are welcome: GitHub - pedrorolo/active_module: ActiveModel/ActiveRecord module attribute type implementation

Interesting, looking forward to give a try.

1 Like

Fun idea!

After a quick look at the README docs, one concern is that it only documents assigning a list of possible_modules as module references at class-loading time.

This could cause circular dependency issues if you e.g. have

class A < ActiveRecord::Base
  attribute :x, :active_module, possible_modules: [B]
end

class B < ActiveRecord::Base
  attribute :x, :active_module, possible_modules: [A]
end

Might be a good idea to (document that you) support strings and/or lambdas.

Cf. class_name: "SomeString" in Active Record.

1 Like

Hi Henrik,

Thank you for the heads up!

I will experiment to check if I can find any issues with circular dependencies. My intuition tells me that those will not be an issue: that Zeitwerk will manage those problems for us. However, rails developers do indeed resort to class_name for associations, and I must confess I don’t fully understand their motivation for doing so. Let’s hope I don’t have the same problem to sort out…

I haven’t looked into circular dependencies in Ruby for a long time, so I tried it out now.

This example runs, but outputs a warning.

I would be surprised if Zeitwerk changes anything about this, other than not having to write the explicit require_relatives.

# a.rb
class A
  require_relative "b"
  def say = puts "hi A"
end

# b.rb
class B
  require_relative "a"
  def say = puts "hi B"
end

# runner.rb
require_relative "a"
require_relative "b"
A.new.say
B.new.say
ruby -w runner.rb

/private/tmp/foo/b.rb:2: warning: /private/tmp/foo/b.rb:2: warning: loading in progress, circular require considered harmful - /private/tmp/foo/a.rb
…

hi A
hi B

I acknowledge your example does indeed answer with that warning. However, I did not get any kind of warning when I trying to replicate the same problem in a rails application using your former example pertaining the active_module gem, either in dev or in production mode:

# models/a.rb
class A < ApplicationRecord
  attribute :field, :active_module, possible_modules: [B]
end

#models/b.rb
class B < ApplicationRecord
  attribute :field, :active_module, possible_modules: [A]
end

#controllers/test_controller.rb
class TestController < ApplicationController
  def index
    A.new(field: B)
    B.new(field: A)
    render plain: 'Hello, Banana'
  end
end

─▪ rails s -e production
=> Booting Puma
=> Rails 7.1.5.1 application starting in production 
=> Run `bin/rails server --help` for more startup options
W, [2024-12-22T20:36:11.417528 #88583]  WARN -- : You are running SQLite in production, this is generally not recommended. You can disable this warning by setting "config.active_record.sqlite3_production_warning=false".
Puma starting in single mode...
* Puma version: 6.5.0 ("Sky's Version")
* Ruby version: ruby 3.2.3 (2024-01-18 revision 52bb2ac0a6) [arm64-darwin23]
*  Min threads: 5
*  Max threads: 5
*  Environment: production
*          PID: 88583
* Listening on http://0.0.0.0:3000
Use Ctrl-C to stop
I, [2024-12-22T20:36:13.531121 #88583]  INFO -- : [83bb7c72-0865-4333-b4df-4d9db5344483] Started GET "/" for 127.0.0.1 at 2024-12-22 20:36:13 +0000
I, [2024-12-22T20:36:13.534898 #88583]  INFO -- : [83bb7c72-0865-4333-b4df-4d9db5344483] Processing by TestController#index as HTML
I, [2024-12-22T20:36:13.556391 #88583]  INFO -- : [83bb7c72-0865-4333-b4df-4d9db5344483] Completed 200 OK in 21ms (Views: 0.9ms | ActiveRecord: 2.5ms | Allocations: 8745)

From this little test I am assuming that Rails is somehow properly handling this problem. If somebody can confirm me that this is an issue please let me know.

However, when launching your little example with the a.rb; b.rb, using zeitwerk I do indeed get the error your are talking about

┌─[tmp][]
└─▪ cat a.rb
require_relative 'b'

class A
  def say
    "say a"
  end
end
┌─[tmp][]
└─▪ cat b.rb
require_relative 'a'

class B
  def say
    "say b"
  end
end
┌─[tmp][]
└─▪ cat runner.rb 
# runner.rb
require 'zeitwerk'
loader = Zeitwerk::Loader.new
loader.push_dir("./")
loader.setup # ready!
A.new.say
B.new.say
┌─[tmp][]
└─▪ ruby -w runner.rb 
/Users/pedrorolo/tmp/b.rb:1: warning: /Users/pedrorolo/tmp/b.rb:1: warning: loading in progress, circular require considered harmful - /Users/pedrorolo/tmp/a.rb
        from runner.rb:6:in  `<main>'
        from /Users/pedrorolo/.rvm/gems/ruby-3.2.3/gems/zeitwerk-2.7.1/lib/zeitwerk/core_ext/kernel.rb:26:in  `require'
        from <internal:/Users/pedrorolo/.rvm/rubies/ruby-3.2.3/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:136:in  `require'
        from <internal:/Users/pedrorolo/.rvm/rubies/ruby-3.2.3/lib/ruby/site_ruby/3.2.0/rubygems/core_ext/kernel_require.rb>:136:in  `require'
        from /Users/pedrorolo/tmp/a.rb:1:in  `<top (required)>'
        from /Users/pedrorolo/tmp/a.rb:1:in  `require_relative'
        from /Users/pedrorolo/tmp/b.rb:1:in  `<top (required)>'
        from /Users/pedrorolo/tmp/b.rb:1:in  `require_relative'

Anyway, if a AR model needs the referenced module to refer to itself, it can always inject itself or pass itself as argument, thus circunventing that problem:

class ARClass < ActiveRecord::Base
  attribute :field, :active_module, possible_modules: [B]

  def run_field
     field.call(self)
     #or
     field.new(self).call
  end
end

I had forgoten to remove the requires from the a and b files! Zeitwerk does indeed deal well with this kind of circular dependencies:

┌─[tmp][]
└─▪ cat a.rb 
class A
  B.new
  def say
    puts "say a"
  end

  def say_b
    B.new.say
  end
end
┌─[tmp][]
└─▪ cat b.rb 
class B
  A.new
  def say
    puts "say b"
  end

  def say_a
    A.new.say
  end
end
┌─[tmp][]
└─▪ cat runner.rb 
# runner.rb
require 'zeitwerk'
loader = Zeitwerk::Loader.new
loader.push_dir("~/tmp")
loader.setup # ready!
A.new.say
B.new.say
┌─[tmp][]
└─▪ ruby -w runner.rb 
say a
say b
┌─[tmp][]
└─▪ 

Ah, interesting! I did not think Zeitwerk changed anything regarding circular dependencies, but I see it does. Their README docs a more complex kind of circular dependency as something to avoid, which seems to implicitly suggest that simpler kinds of circular dependencies are not seen as a problem.

(cc @fxn – would be super interesting to hear more about this if you’re willing.)

After further thought, I guess there’s nothing special going on in the Zeitwerk case – it’s probably just that because it knows that A has already been required, it doesn’t attempt to require it again when B references it, so no warning is triggered. Same as this situation without Zeitwerk:

# a.rb
class A
  require_relative "b"
  B.name
  def say = puts "hi A"
end

# b.rb
class B
  A.name
  def say = puts "hi B"
end

# runner.rb
require_relative "a"

A.new.say
B.new.say
$ ruby -w runner.rb
hi A
hi B

I think in summary, you’d get to a place where you don’t have this warning (which is nice) but you are of course still at theoretical risk of confusing situations if the circular dependencies get more complex. In many cases, it will be fine.

Yeah, this works just fine with Zeitwerk, let me explain.

You have to imagine that on setup the loader defines autoloads for A, and for B, before any of them can be reached by client code. “autoloads” means Module#autoload calls for :A and :B in Object pointing to the absolute paths of their respective files.

Now, if you autoload A and B is referenced in the body of A, that is going to trigger the autoload of B when that line is hit. The important observation is that since that is happening in the body of A, the A class is already defined. Partially, but defined. Therefore, when B gets autoloaded due to that reference, and there is an A in there, since it is already in RAM, it can be resolved like any known constant.

If the body of B tried to use a class method of A defined at the bottom of its class body, it would fail. But that is, again, standard Ruby logic. You know.

2 Likes

Well… this little drift towards autoloading made me replace my gem’s manual requires by Zeitwerk! :slight_smile: use zeitwerk instead of manual requires · pedrorolo/active_module@3be7d9d · GitHub

1 Like