DEV Community

Cover image for Create a Ruby gem from scratch
Stan
Stan

Posted on • Edited on

Create a Ruby gem from scratch

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:

bundle gem commenter
Enter fullscreen mode Exit fullscreen mode

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

At present, we have the following file structure.

├── Gemfile
├── Gemfile.lock
├── commenter-0.1.0.gem
├── commenter.gemspec
├── lib
│   ├── commenter
│   │   └── version.rb
│   ├── commenter.rb
└── spec
    ├── commenter_spec.rb
    └── spec_helper.rb
Enter fullscreen mode Exit fullscreen mode

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

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

module Commenter
  class Error < StandardError; end
end
Enter fullscreen mode Exit fullscreen mode
# lib/commenter/version.rb
module Commenter
  VERSION = '0.1.0'
end
Enter fullscreen mode Exit fullscreen mode

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

Additionally, we will need 2 dependencies for this gem.

spec.add_dependency "rails"
spec.add_dependency "rspec-rails"
Enter fullscreen mode Exit fullscreen mode

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

require 'active_support/concern'

module Commenter
  module Commentable
    extend ActiveSupport::Concern

    included do
      has_many :comments, as: :commentable, dependent: :destroy
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

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

require_relative "commenter/version"
require_relative "commenter/commentable"

module Commenter
  class Error < StandardError; end
end
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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.

# ...

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
Enter fullscreen mode Exit fullscreen mode

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

class <%= class_name %> < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end
Enter fullscreen mode Exit fullscreen mode

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.

spec.add_development_dependency "sqlite3"
spec.add_development_dependency "generator_spec"
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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.

gem build commenter.gemspec
Enter fullscreen mode Exit fullscreen mode

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.

gem 'commenter', path: 'path/to/commenter`
Enter fullscreen mode Exit fullscreen mode

Once done, run bundle install.

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

rails generate commenter:comment Comment
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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.

<%= 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 %>
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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.

class Article < ApplicationRecord
    has_many :comments, as: :commentable, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

resources :articles do
  resources :comments, only: [:create, :destroy]
end 
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)