Create a Ruby gem from scratch
Tags: ruby, rails, beginners, tutorial
Published: 2023-04-22 10:27:30

If you've ever needed to add commenting functionality to a Rails project, you may have found yourself writing the same code over and over again. In this article, we'll show you how to create a gem that provides comments for any model in a Rails project. With this gem, you'll be able to easily add commenting functionality to any Rails project without duplicating code.


## Create the gem
First, we'll create a new gem called commenter. To do this, run the following command in your terminal:

```bash
bundle gem commenter
```

This will generate a new gem with a basic file structure.

At present, we  have the following file structure.

```bash
├── Gemfile
├── Gemfile.lock
├── commenter-0.1.0.gem
├── commenter.gemspec
├── lib
│   ├── commenter
│   │   └── version.rb
│   ├── commenter.rb
└── spec
    ├── commenter_spec.rb
    └── spec_helper.rb
```

We already have the following files present in the lib directory.

```ruby
# lib/commenter.rb
require_relative "commenter/version"

module Commenter
  class Error < StandardError; end
end
```

```ruby
# lib/commenter/version.rb
module Commenter
  VERSION = '0.1.0'
end
```

Update the `commenter.gemspec` file by replacing all the `TODO: ` statements with relevant information.

Additionally, we will need 2 dependencies for this gem.

```ruby
spec.add_dependency "rails"
spec.add_dependency "rspec-rails"
```

First, let's include the associated comments using `ActiveSupport::Concern` in the `lib/commenter/commentable.rb` file.

```ruby
require 'active_support/concern'

module Commenter
  module Commentable
    extend ActiveSupport::Concern

    included do
      has_many :comments, as: :commentable, dependent: :destroy
    end
  end
end
```

We will then require this file in `lib/commenter.rb`.

```ruby
require_relative "commenter/version"
require_relative "commenter/commentable"

module Commenter
  class Error < StandardError; end
end
```

Next, we will create a generator to setup up the migration for creating comments in the database in the `lib/generators/commenter/comment_generator.rb` file.

```ruby
require 'rails/generators/active_record'
require 'rails/generators/named_base'


module Commenter
  module Generators
    class CommentGenerator < Rails::Generators::NamedBase
      source_root File.expand_path('templates', __dir__)
      def create_migration
        migration_file_name = "create_comments.rb"
        timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
        destination = File.join('db', 'migrate', "#{timestamp}_#{migration_file_name}")
        template migration_file_name, destination
      end
    end
  end
end
```

Additionally, we will create a template for this migration in the `lib/generators/commenter/templates/create_comments.rb` file.

```ruby
class CreateComments < ActiveRecord::Migration[6.1]
  def change
    create_table :comments do |t|
      t.text :body
      t.references :commentable, polymorphic: true, null: false
      t.timestamps
    end
  end
end
```

So far we have set up the gem to create a migration file in the `db/migrate` folder of your Rails application with the correct timestamp. As can be seen, the migration file contains a `body` attribute, `timestamps` as well as references to `commentable` which will be polymorphic.

Now, we will add the method to generate the model in the same generator.


```ruby
# ...

module Commenter
  module Generators
    class CommentGenerator < Rails::Generators::NamedBase

      # ...

      def create_model_file
        template 'comment.rb', File.join('app/models', class_path, "#{file_name}.rb")
      end
    end
  end
end
```

We will also set up the template to create the model in the `lib/generators/commenter/templates/comment.rb` file.

```ruby
class <%= class_name %> < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end
```

## Let's test
At this stage our code is ready. However, let's set up the specs to ensure that the generators and associations work fine.

For this purpose, we will add 2 more development dependencies to our gemspec.

```ruby
spec.add_development_dependency "sqlite3"
spec.add_development_dependency "generator_spec"
```

For testing purposes, we will use the `sqlite3` database.

We will also update the `spec_helper.rb` file to ensure that we have all the configurations and requirements set up for testing.

```ruby
require "bundler/setup"
require "commenter"
require "rails"
require "action_view"
require "action_controller"
require "rspec/rails"
require "active_record"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define do
  create_table :commentable_models do |t|
    t.timestamps
  end
end

RSpec.configure do |config|
  config.filter_run_when_matching :focus
  config.example_status_persistence_file_path = "spec/examples.txt"
  config.disable_monkey_patching!
  config.default_formatter = "doc" if config.files_to_run.one?
  config.order = :random
  Kernel.srand config.seed
end
```

Here, we have established a connection with the `sqlite3` and created an `ActiveRecord` migration for our commentable models.

By default, we have the `spec/commenter_spec.rb` file. We can keep the spec for testing the version number, and get rid of the second placeholder spec.

Next, we will create `spec/commenter/commentable_spec.rb` file to test the association.

```ruby
require "spec_helper"

RSpec.describe Commenter::Commentable, type: :model do
  class CommentableModel < ActiveRecord::Base
    include Commenter::Commentable
  end

  describe "associations" do
    it "has many comments" do
      association = CommentableModel.reflect_on_association(:comments)
      expect(association.macro).to eq(:has_many)
      expect(association.options[:as]).to eq(:commentable)
      expect(association.options[:dependent]).to eq(:destroy)
    end
  end
end
```

Next, we set up the specs for testing the generator in the `spec/generators/commenter/comment_generator_spec.rb` file.


```ruby
require 'spec_helper'
require 'generator_spec'
require 'generators/commenter/comment_generator'

RSpec.describe Commenter::Generators::CommentGenerator, type: :generator do
  destination File.expand_path("../../tmp", __FILE__)
  arguments %w(Comment)

  before do
    prepare_destination
    run_generator
  end

  it 'creates the comments migration' do
    migration_path = File.join(destination_root, 'db/migrate')
    expect(File.exist?(migration_path)).to be true
    migration_files = Dir.entries(migration_path)
    expect(migration_files).to include(/create_comments/)
  end

  it 'creates the comments model' do
    model_path = "#{destination_root}/app/models/comment.rb"
    expect(File.exist?(model_path)).to be true
    model_contents = File.read(model_path)
    expect(model_contents).to match(/class Comment < ApplicationRecord/)
  end

  after(:all) do
    FileUtils.rm_rf(destination_root)
  end
end
```

In this spec, we prepare a temporary destination for the generated files which will be deleted on completion of the spec. The first spec tests the generation of the migration file, whereas the second one tests the generation of the model.

Now, let's get the spec ready for use in a Rails application.

First, let's build the gem.

```bash
gem build commenter.gemspec
```

## Hook it up to rails

Next, let's add the gem to our Rails application `Gemfile`. At present, since we haven't uploaded it to the https://rubygems.org repository yet, let's source it locally.

```ruby
gem 'commenter', path: 'path/to/commenter`
```

Once done, run `bundle install`.

With the gem added, let's use it to generate our migration and model files.

```bash
rails generate commenter:comment Comment
```

This command should create our migration file in `db/migrate` as well as our `Comment` model in `app/models`.

Next, let's create our `comments_controller.rb` file.

```ruby
class CommentsController < ApplicationController
  before_action :set_commentable

  def create
    @comment = @commentable.comments.build(comment_params)
    if @comment.save
      redirect_to @commentable, notice: 'Comment was successfully created.'
    else
      redirect_to @commentable, alert: 'Error creating comment.'
    end
  end

  def destroy
    @comment = @commentable.comments.find(params[:id])
    @comment.destroy
    redirect_to @commentable, notice: 'Comment was successfully destroyed.'
  end

  private

  def set_commentable
    @commentable = find_commentable
  end

  def find_commentable
    params.each do |name, value|
      if name =~ /(.+)_id$/
        return $1.classify.constantize.find(value)
      end
    end
    nil
  end

  def comment_params
    params.require(:comment).permit(:body)
  end
end
```

The controller has 2 basic actions - `create` and `destroy`. Additionally, we have set up our `find_commentable` method to search for the commentable class based on the associated `id` provided.

We will also set up a couple of views. First, let's create a `app/views/comments/_comment_form.html.erb` view to create a comment.

```erb
<%= form_with model: [commentable, Comment.new], local: true do |form| %>
  <div class="form-group">
    <%= form.label :body, "Comment" %>
    <%= form.text_area :body, class: 'form-control' %>
  </div>
  <%= form.submit 'Add Comment', class: 'btn btn-primary' %>
<% end %>
```

Next, we create a `app/views/comments/_comment.html.erb` view for each individual comment.

```html
<div class="comment">
  <p><%= comment.body %></p>
  <p>
    <%= button_to 'Delete Comment', [comment.commentable, comment],
                method: :delete,
                data: { confirm: 'Are you sure you want to delete this comment?' },
                class: 'btn btn-danger btn-sm' %>
  </p>
</div>
```

Let's assume we already have a model called `Article` that has been set up inside our Rails application. We will add the comments to our `Article` model.

```ruby
class Article < ApplicationRecord
    has_many :comments, as: :commentable, dependent: :destroy
end
```

Next, we will plug in our comment and form into the show page of our article.

```html
<p style="color: green"><%= notice %></p>

<%= render @article %>

<div class="comments">
  <h3>Comments</h3>
  <%= render partial: 'comments/comment', collection: @article.comments %>
  <%= render partial: "comments/comment_form", locals: { commentable: @article } %>

</div>
```

Finally, let's add comments to our `article` routes.

```ruby
resources :articles do
  resources :comments, only: [:create, :destroy]
end 
```

## The final result

Our application is now ready to create comments. Let's spin up our Rails application and visit the show page of any of our articles e.g. `http://localhost:3000/articles/1`

If all has worked well so far, we should now be able to add and delete comments on this page.


![Commenter gem](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9s1z28yrda52qdob8zvi.png)