Namespaced model associations constant lookups

Creating associations between namespaced models has always left me confused

It’s not straightforward whether reference columns, association names, etc; need to include the namespace as a prefix or not:

> rails g model Admin::Post title content
> rails db:migrate
> rails g model Admin::Comment text post:belongs_to
> rails db:migrate
❯ rails runner "Admin::Comment.create(text: 'hello', post: Admin::Post.create!(title: 'Hello World'))"                                                                                        
   ...
  1.4.2/lib/sqlite3/database.rb:147:in `initialize': no such table: main.posts (SQLite3::SQLException)

❯ rails g model Admin::Commentt text admin_post:belongs_to
> rails db:migrate
❯ rails runner "Admin::Commentt.create(text: 'hello', admin_post: Admin::Post.create!(title: 'Hello World'))" 
  uninitialized constant Admin::Commentt::AdminPost

The last example only starts working after editing the model:

class Admin::Commentt < ApplicationRecord
  belongs_to :admin_post, class_name: 'Admin::Post'
end

So, I don’t know if this is a ruby constant resolution problem, or a bug with Rails, but it’s very confusing! and seems Rails is trying to resolve the constant correctly but not being able to.

What version of Ruby are you running? Older Ruby versions had a weird constant resolution issue where the two constant definitions below would not be seen as the same constant:

module Admin
  module Post
  end
end

module Admin::Post 
end

I tested the above in 2.6.5 :slight_smile:

Why did Comment become Commentt in the file name?

It’s just a second model.

Oh, I see what’s going on now.

Rails “constantization” from a string is filepath-oriented, so the inflector assumes the following:

admin/post => Admin::Post  
admin_post => AdminPost

However, when converting a constant to a table name, Rails’s inflector can’t use /. So instead things get flattened a bit:

Admin::Post => admin_posts  
AdminPost   => admin_posts

When the inflector attempts to convert this back to constant form, we run into trouble:

Admin::Post => admin_posts => AdminPost  
AdminPost   => admin_posts => AdminPost

I was a little surprised when I read your post (hence my “…which Ruby version?”) because my team uses associations between namespaced models a lot and I don’t remember there having been any WTFs in a while. But then I remembered that we use a different strategy for it: we don’t include the namespace in the reference/association name. This looks something like:

class Admin::Post 
  has_many :comments  # Constantizes to `Comment`.
                      # Ruby module lookup looks at Admin::Comment first.
                      # Admin::Comment exists! We have module resolution.

  belongs_to :user, class_name: `Auth::User` # This is in a diff namespace.
                                             # So we need to specify module.
                                             # We do this with class_name.
end

Note that if you’re using this strategy alongside database foreign key constraints, you will need to use the to_table option in your migrations. It looks like this:

t.references :user, foreign_key: { to_table: "auth_users" }

OOF. That was a bit of a dive into the Rails inflector + Ruby module lookup! What kinds of documentation do you think might help make the nuances clearer here?

2 Likes

Thank you for taking the time to explain this!

This works (foreign key, belongs_to in generator):

  > rails g Admin::Post title content
  > rails g model Admin::Post title content
  > rails g model Admin::Comment text post:belongs_to
  > vim db/migrate/20200515143721_create_admin_comments.rb
  # 
  # t.belongs_to :post, null: false, foreign_key: { to_table: 'admin_posts' }
  #  
  > rails db:migrate
  > rails runner "Admin::Comment.create(text: 'hello', post: Admin::Post.create!(title: 'Hello World'))"

And this also works (plain-old belongs_to via id on generator):

> rails g model Admin::Commentt text post_id:integer
> vim app/models/admin/commentt.rb
#
# # Add belongs_to :post manually
# class Admin::Commentt < ApplicationRecord
#   belongs_to :post
# end
> rails runner "Admin::Commentt.create(text: 'hello', post: Admin::Post.create!(title: 'Hello World'))"

So it seems the issue is a misconception I had that the rails generator that creates the FK automatically would follow the ruby constant resolution. It makes sense now, but I think it differs from how Ruby would behave automatically and it also differs from how it behaves without a FK…

So… would it make sense for the generator to look up if there’s any model in the current namespace when attempting to create the belongs_to FK? Perhaps the generator could provide some feedback as to which model it’s being associated to? Or could this be solved just with better documentation / education?

2 Likes

I like the idea of making the generator smarter here.

Namespaced models is one of the areas in Rails I’ve always had to be extra careful around. Had a bunch of weird bugs due to autoloading.

1 Like

Would you mind telling me more about those autoloader bugs? One of the reasons we started the May of WTFs was the difficulty of making good autoloader bug reports.

Can’t remember most of them as it’s been quite some time ago. I do remember going WTF quite a few times before understanding how Rails’ autoloading works.

Here’s one caused by interaction between my own Product model and the Spree::Product model defined by Solidus.

https://github.com/solidusio/solidus/pull/1615

Think I also had some weird interactions in development with concerns namespaced under the same model, eg. model named Project and concern named Project::Something. Would get different behavior in a console depending on what I loaded first. Decided to avoid duplicate constant names altogether and renamed it to Projects::Something This was in Rails 5, not sure whether it’s still an issue.

1 Like