Rails 4.2 Tutorial 4 - Tagging with PostgreSQL array

  • Mar 9

Tagging is a very common practice in web application. There are many good gems to begin with. But each application uses tags differently and it is quite easy to make one which fits your application best. Here, we will also use PostgreSQL array to implement tags. Actually, there is an existing gem for that: acts-as-taggable-array-on. But the scenario is slightly different in our case.

First, we need a model to be tagged. Since we have users already, we will create model Topic and users can have many topics and topics might belongs to many users with different permission.

$ bin/rails g scaffold Topic title:text description:text public:boolean
$ bin/rails g model Ownership user:references topic:references permission:integer

The resulted migration are slightly edited:

class CreateTopics < ActiveRecord::Migration
  def change
    create_table :topics do |t|
      t.text :title,         null: false
      t.text :description
      t.boolean :public, default: false

      t.timestamps           null: false
    end
  end
end
class CreateOwnerships < ActiveRecord::Migration
  def change
    create_table :ownerships do |t|
      t.references :user, index: true
      t.references :topic, index: true
      t.integer :permission, default: 1

      t.timestamps null: false
    end
    add_foreign_key :ownerships, :users, on_delete: :cascade
    add_foreign_key :ownerships, :topics, on_delete: :cascade
  end
end

You can use null: false to prevent a field from having null value; default: 1 for default value and on_delete: :cascade for foreign key to keep database integrity.

Let's look at the relationship between user and topic first:

# app/model/user.rb
class User < ActiveRecord::Base
  include OmniauthCallbacks

  has_many :ownerships
  has_many :topics, :through => :ownerships

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :validatable, :recoverable and :registerable
  devise :database_authenticatable, :omniauthable, :rememberable, :trackable

end
# app/models/topic.rb 
class Topic < ActiveRecord::Base
  has_many :ownerships
  has_many :users, through: :ownerships
end
# app/models/ownership.rb 
class Ownership < ActiveRecord::Base
  belongs_to :user
  belongs_to :topic

  enum permission: {owner: 1, subscriber: 4}
end

This is a typic many-to-many relationship. Note that the use of ActiveRecord::Enum on permission. It is an easy way to support enum attribute.

Run bin/rake db:migrate to create database.

Now, we can start to test these relationships to make sure they are properly connected.

# test/fixtures/topics.yml
topic_one:
  title: Topic One Title
  description: Topic One Description
  public: false

topic_two:
  title: Topic Two Title
  description: Topic Two Description
  public: false

topic_three:
  title: Topic Three Title
  description: Topic Three Description
  public: false
# test/fixtures/ownerships.yml
ownership_one:
  user: user_one
  topic: topic_one
  permission: 1

ownership_two:
  user: user_two
  topic: topic_two
  permission: 1

ownership_three:
  user: user_two
  topic: topic_three
  permission: 1

Then we can test like this:

# test/models/user_test.rb 
require 'test_helper'

class UserTest < ActiveSupport::TestCase
  test "User one has one topic" do
    user = users(:user_one)
    assert_equal user.topics.count, 1
  end

  test "User two has two topic" do
    user = users(:user_two)
    assert_equal user.topics.count, 2
  end
end
# test/models/topic_test.rb 
require 'test_helper'

class TopicTest < ActiveSupport::TestCase
  test "Topic one has one hyperlink" do
    topic = topics(:topic_one)
    assert_equal topic.hyperlinks.count, 1
  end                                                                                                         
                                                                                                              
  test "Topic three has two hyperlinks" do
    topic = topics(:topic_three)
    assert_equal topic.hyperlinks.count, 2
  end   

  test "User two is the owner of topic two" do
    user = users(:user_two)
    topic = topics(:topic_two)
    assert_includes topic.users, user
  end   

  test "User one is not the owner of topic two" do
    user = users(:user_one)
    topic = topics(:topic_two)
    assert_not_includes topic.users, user
  end   
end

As for the default controller test, remember to sign-in since all actions need user sign-in:

# test/controllers/topics_controller_test.rb

class TopicsControllerTest < ActionController::TestCase
  setup do
    @topic = topics(:topic_one)
    @user = users(:user_one)
    sign_in @user
  end

If you have problem about foreign key constraint, your user role in PostgreSQL might need to be supervisor. Run psql and execute:

ALTER ROLE bookmark SUPERVISOR

Since a topic can belong to differnet users, each user might tag a topic differently. Therefore, we cannot put the tags on topics. Instead, we will store the tags in ownerships. To add PostgreSQL to model, try this:

$ bin/rails g migration AddTagsToOwnership tags:text

And the migration file:

class AddTagsToOwnership < ActiveRecord::Migration
  def change
    add_column :ownerships, :tags, :text, array: true
    add_index :ownerships, :tags, using: 'gin'
  end
end

By assigning arrays: true, it turns the attribute tags into PostgreSQL array.

Then you can use the fields like this:

ownership = Ownership.first
ownership.update_attribute(:tags, ['red', 'white'])

ownership.tags << 'blue'
ownership.save!

Rails does not track the changes of PostgreSQL array perfectly. It might be wise to notify the change of attribute:

ownership.tags.delete('red')
ownership.tags_will_change!
ownership.save!

The use of #{attribute_name}_will_change! is the key.

Then we can copy what acts-as-taggable-array-on does, but apply it on Ownership model. We put the code in Concern like this:

# app/models/concerns/taggable.rb 
module TagParser  
  class Parser
    def parse tags
      case tags
      when String
        tags.split(/[ ]*,[ ]*/)
      else
        tags
      end
    end
  end
end

module Concerns::Taggable
  extend ActiveSupport::Concern

  included do
#    taggable_on :folders
    taggable_on :tags
  end

  module ClassMethods
    def taggable_on(field_name)
      tag_names = field_name.to_s.gsub(/[^A-Za-z]/, '') # Sanitize
      parser = TagParser::Parser.new
      tag_name = tag_names.singularize

      Topic.scope :"with_any_#{tag_name}", ->(tags){ joins(:ownerships).where("ownerships.#{tag_names} && ARRAY[?]", parser.parse(
tags)) }
      Topic.scope :"with_all_#{tag_names}", ->(tags){ joins(:ownerships).where("ownerships.#{tag_names} @> ARRAY[?]", parser.parse
(tags)) }
      Topic.scope :"without_any_#{tag_name}", ->(tags){ joins(:ownerships).where.not("ownerships.#{tag_names} && ARRAY[?]", parser
.parse(tags)) }
      Topic.scope :"without_all_#{tag_names}", ->(tags){ joins(:ownerships).where.not("ownerships.#{tag_names} @> ARRAY[?]", parse
r.parse(tags)) }
      Topic.scope :"all_#{tag_names}", -> { joins(:ownerships).select("unnest('ownerships.#{tag_names})").uniq.pluck(tag_names).co
mpact }
      class_eval %Q{
        def #{tag_names}_by(user:)
          o = Ownership.find_by(user: user, topic: self)
          o.present? ? o.#{tag_names} : nil
        end
      }
    end
  end
end

Then include it in Topic:

class Topic < ActiveRecord::Base
  has_many :ownerships
  has_many :users, through: :ownerships

  include Concerns::Taggable
end

Finally, a few test to make sure it works:

# test/models/tag_test.rb 
require 'test_helper'

class TopicTest < ActiveSupport::TestCase
  test "Topic has tags" do
    user = users(:user_one)
    topic = topics(:topic_one)
    assert topic.tags_by(user: user).blank?
  end                                                                                                         

  test "Topic can set tags" do
    user = users(:user_one)
    topic = topics(:topic_one)
    ownership = Ownership.find_by(user: user, topic: topic)
    ownership.tags = ['summer']
    ownership.save!
    assert topic.tags_by(user: user).include?('summer')
  end                                                                                                         

  test "Topic can add tags" do
    user = users(:user_one)
    topic = topics(:topic_one)
    ownership = Ownership.find_by(user: user, topic: topic)
    ownership.tags = ['summer']
    ownership.save!
    ownership.tags << 'swim'
    ownership.save!
    assert topic.tags_by(user: user).include?('swim')
  end                                                                                                         

  test "Topic can delete tags" do
    user = users(:user_one)
    topic = topics(:topic_one)
    ownership = Ownership.find_by(user: user, topic: topic)
    ownership.tags = ['summer', 'swim']
    ownership.save!
    assert topic.tags_by(user: user).include?('swim')
    ownership.tags.delete 'swim'
    ownership.tags_will_change!
    ownership.save!
    assert_not topic.tags_by(user: user).include?('swim')
  end                                                                                                         

  test "Find topic with tag" do
    user = users(:user_one)
    topic_one = topics(:topic_one)
    ownership = Ownership.find_by(user: user, topic: topic_one)
    ownership.update_attribute(:tags, ['summer', 'swim'])
    assert topic_one.tags_by(user: user).include?('swim')
    assert_equal topic_one, user.topics.with_any_tag('summer').first
  end                                                                                                         

  test "Find topic with any tag" do
    user_one = users(:user_one)
    user_two = users(:user_two)
    topic_one = topics(:topic_one)
    topic_two = topics(:topic_two)
    Ownership.find_by(user: user_one, topic: topic_one).update_attribute(:tags, ['summer', 'swim'])
    Ownership.find_by(user: user_two, topic: topic_two).update_attribute(:tags, ['winter', 'swim'])
    assert_equal 1, user_one.topics.with_any_tag('summer, winter, spring, fall').count
    assert_equal 2, Topic.with_any_tag('summer, winter, spring, fall').count
  end                                                                                                         

  test "Find topic with all tags" do
    user_one = users(:user_one)
    user_two = users(:user_two)
    topic_one = topics(:topic_one)
    topic_two = topics(:topic_two)
    Ownership.find_by(user: user_one, topic: topic_one).update_attribute(:tags, ['summer', 'swim'])
    assert user_one.topics.with_all_tags('summer, winter, spring, fall').blank?
    assert_equal 1, user_one.topics.with_all_tags('summer, swim').count
  end                                                                                                         
end