Reopen And Add Methods To Models In Ruby Gems
This is a documentation on how to add class and instance methods to models that exist in ruby gems. Often, there is a need to add methods to models that are created in ruby gems.
In a recent project that I am working on, I found a particular need for adding images to a tagging gem. The purpose of the gem is for taxonomy and the final UI draft has different images allocated to each of the category (or should I say tag). We decided to have the rails backend handle the image and tag association. Hence, the ideal way to handle this would be to modify the models in the tagging gem to hold its image as well upon creation.
Project Specifics
The tagging gem that I am using is, contrary to the more popular and senior ActsAsTaggableOn
gem, the Gutentag
gem.
The reason I use the latter instead of the former is because the former does not support the new ActiveRecord
6 when I was working on the project. It returns erroneous results and throws error due to deprecated ActiveModel
method in its normal usage for example.
The alternative I found is Gutentag
. It support Rails 6
and the contributors are actively resolving issues, keeping its issues count at 0 at the time of writing. I found it reliable and it does its main job well, which is to provide the tagging module.
The only thing it lacks for this particular project is an image to associate with for each tag. Here is where I would need to hack it.
I want to add image to each tag using ActiveStorage
via has_on_attached
method, and also a custom instance method that will return the tag’s name and image url.
The Rationale
The way I am doing it is to create a module that defines the relevant methods, and have the Gutentag::Tag
model include this custom module. I will include it during the initialization phase. This will require some workarounds because of we are accessing the ActiveStorage
and ActiveModel/ActiveRecord
railitie sduring the initialization phase where these railities are not loaded yet.
The Extension Module
Kudos to this answer on stackoverflow, define the extension module as such:
# lib/extensions/gutentag.rb
# frozen_string_literal: true
module Extensions
module Gutentag
extend ActiveSupport::Concern
included do
has_one_attached :image
end
def json_attributes
custom_attributes = attributes.dup
custom_attributes.delete 'created_at'
custom_attributes.delete 'updated_at'
custom_attributes.delete 'taggings_count'
custom_attributes.delete 'id'
# add image path base on service used
if Rails.env.test? || Rails.env.development?
ActiveStorage::Current.set(host: 'http://localhost:3000') do
custom_attributes['image'] = self.image.attached? ? self.image.service_url : nil
end
else
custom_attributes['image'] = self.image.attached? ? self.image.service_url : nil
end
custom_attributes
end
end
end
I modified it slightly with the use of ActiveSupport::Concern
to do it the Rails 6
way. This helps to resolve module dependencies gracefully.
In this extension, I attached an image to the module using ActiveStorage
‘s has_one_attached
class method, which will ultimately be applied to the Gutentag::Tag
model.
I also defined the instance method json_attributes which will return only the name and the image url in the resultant tag when called. It is used in the api response when frontend clients are retrieving the list of tags for example.
The Initialization
The code will be added to the original Gutentag
initializer file under config/initializers/gutentag.rb
.
# frozen_string_literal: true
require 'extensions/gutentag'
Gutentag.normaliser = lambda { |value| value.to_s }
Rails.application.config.to_prepare do
begin
if ActiveRecord::Base.connection.table_exists?(:gutentag_tags)
Gutentag::Tag.include Extensions::Gutentag
end
rescue ActiveRecord::NoDatabaseError
end
end
The extension file is imported in line 3.
Line 5 is one of the provided original Gutentag configuration option. This is specific to my project and is trivial in relation to the topic of this article. I am leaving it here to show other Gutentag configuration changes will co-exist with this custom module of mine.
Line 10 is the main line of code to execute. It will add the module to the Gutentag::Tag
model which is defined inside the source code of the Gutentag
gem. However, as you can see, it is wrapped in a number of codes. Not doing so will result in errors.
Here is why.
As we are going to involve the ActiveRecord
and ActiveSupport
railities, which have not been initialized yet during the default rails initialization phase, we need to ensure we run the code after they have been loaded.
Rails has 5 initialization events. The first initialization event to fire off after all railities are loaded is to_prepare
, hence we define the code after that happens inside its block.
Since we are interacting with a ActiveRecord
model, during the initialization phase, it is possible that the table has not been created. In other words, the Gutentag tables migration has not been executed, resulting in errors about the table not existing. An if conditional check is done to prevent this error.
I am not handling the else condition as under normal circumstances, after the proper migration has been executed, this will not happen. A possible scenario that this would happen is during rake tasks to create or migrate the database, either of which does not use the new methods at all.
A non-existing table is not the only thing we have to guard when dealing with Railities during the initialization process. A non-existing database is also a probable scenario that may occur. An example is during the rails db:create
step. Hence, we rescue the ActiveRecord::NoDatabaseError
error to silence the error. As this is often the only scenario that will happen, I will not handle the exception in the rescue block.
Usage
Now we can use it in our application. For instance, I can seed some default tags with images attached to them as shown:
# db/seeds.rb
# frozen_string_literal: true
p 'Creating Tags'
[
'Luffy',
'Zoro',
'Usopp',
'Sanji',
'Nami',
'Chopper',
'Robin',
'Frankie',
'Brooks',
].each do |name|
tag = Gutentag::Tag.create!(name: name)
tag.image.attach(io: File.open("#{Rails.root.join('app', 'assets', 'images')}/#{name}_avatar.jpg"), filename: "#{name}_image.jpg")
tag.save!
end
p 'Tags created'
Then in my api response for listing the tags, I can use the json_attributes method as such:
# app/controllers/api/v1/tags_controller.rb
# frozen_string_literal: true
module Api
module V1
class TagsController < Api::BaseController
def index
@tags = Gutentag::Tag.order(:name)
end
end
end
end
# app/views/api/v1/tags/index.json.jbuilder
json.tags do
json.array! @tags do |tag|
json.merge! tag.json_attributes
end
end