PgTaggable

A simple tagging gem for Rails using PostgreSQL array.

Installation

Add this line to your application's Gemfile:

gem "pg_taggable"

And then execute:

$ bundle

Or install it yourself as:

$ gem install pg_taggable

Usage

Setup

Add array columns and index to your table. For example:

class CreatePosts < ActiveRecord::Migration[8.0]
  def change
    create_table :posts do |t|
      t.string :tags, array: true, default: []

      t.timestamps

      t.index :tags, using: 'gin'
    end
  end
end

Indicate that attribute is "taggable" in a Rails model, like this:

class Post < ActiveRecord::Base
  taggable :tags
end

Modify

You can modify it like normal array

#set
post.tags = ['food', 'travel']

#add
post.tags += ['food']
post.tags += ['food', 'travel']
post.tags << 'food'

#remove
post.tags -= ['food']

Queries

any_#tag_name

Find records with any of the tags.

Post.where(any_tags: ['food', 'travel'])

You can use with not

Post.where.not(any_tags: ['food', 'travel'])

all_#tag_name

Find records with all of the tags

Post.where(all_tags: ['food', 'travel'])

#tag_name_in

Find records that have all the tags included in the list

Post.where(tags_in: ['food', 'travel'])

#tag_name_eq

Find records that have exact same tags as the list, order is not important

Post.where(tags_eq: ['food', 'travel'])

Assume a post has tags: 'A', 'B' |Method|Query|Matched| |-|-|-| |any_tags|A|True| |any_tags|A, B|True| |any_tags|B, A|True| |any_tags|A, B, C|True| |all_tags|A|True| |all_tags|A, B|True| |all_tags|B, A|True| |all_tags|A, B, C|False| |tags_in|A|False| |tags_in|A, B|True| |tags_in|B, A|True| |tags_in|A, B, C|True| |tags_eq|A|False| |tags_eq|A, B|True| |tags_eq|B, A|True| |tags_eq|A, B, C|False|

Class Methods

taggable(name, unique: true)

Indicate that attribute is "taggable".

unique: true

You can use unique option to ensure that tags are unique. It will be deduplicated before saving. The default is true.

# taggable :tags, unique: true
post = Post.create(tags: ['food', 'travel', 'food'])
post.tags
# => ['food', 'travel']

# taggable :tags, unique: false
post = Post.create(tags: ['food', 'travel', 'food'])
post.tags
# => ['food', 'travel', 'food']

#tag_name

Return unnested tags. The column name will be tag, For example:

Post.tags
# => #<ActiveRecord::Relation [#<Post tag: "food", id: nil>, #<Post tag: "travel", id: nil>, #<Post tag: "travel", id: nil>, #<Post tag: "technology", id: nil>]>

Post.tags.size
# => 4

Post.tags.select(:tag).distinct.size
# => 3

Post.tags.distinct.pluck(:tag)
# => ["food", "travel", "technology"]

Post.tags.group(:tag).count
# => {"food"=>1, "travel"=>2, "technology"=>1}

distinct_#tag_name

Return an array of distinct tag records. It can be used for paging, count or other query.

Post.distinct_tags
# => #<ActiveRecord::Relation [#<Post tag: "food", id: nil>, #<Post tag: "travel", id: nil>, #<Post tag: "technology", id: nil>]>

# equal to
Post.tags.select(:tag).distinct

uniq_#tag_name

Return an array of unique tag strings.

Post.uniq_tags
# => ["food", "travel", "technology"]

# equal to
Post.tags.distinct.pluck(:tag)

count_#tag_name

Calculates the number of occurrences of each tag.

Post.count_tags
# => {"food"=>1, "travel"=>2, "technology"=>1}

# equal to
Post.tags.group(:tag).count

any_#tag_name(value, delimiter = ',')

It will create some scopes, this is useful for using ransack

Post.any_tags(['food', 'travel'])

# equal to
Post.where(any_tags: ['food', 'travel'])

Scope support string input

Post.any_tags('food,travel')
Post.any_tags('food|travel', '|')

all_#tag_name(value, delimiter = ',')

Post.all_tags(['food', 'travel'])

# equal to
Post.where(all_tags: ['food', 'travel'])

#tag_name_in(value, delimiter = ',')

Post.tags_in(['food', 'travel'])

# equal to
Post.where(tags_in: ['food', 'travel'])

#tag_name_eq(value, delimiter = ',')

Post.tags_eq(['food', 'travel'])

# equal to
Post.where(tags_eq: ['food', 'travel'])

not_any_#tag_name(value, delimiter = ',')

Post.not_any_tags(['food', 'travel'])

# equal to
Post.where.not(any_tags: ['food', 'travel'])

not_all_#tag_name(value, delimiter = ',')

Post.not_all_tags(['food', 'travel'])

# equal to
Post.where.not(all_tags: ['food', 'travel'])

not_#tag_name_in(value, delimiter = ',')

Post.not_tags_in(['food', 'travel'])

# equal to
Post.where.not(tags_in: ['food', 'travel'])

not_#tag_name_eq(value, delimiter = ',')

Post.not_tags_eq(['food', 'travel'])

# equal to
Post.where.not(tags_eq: ['food', 'travel'])

Case Insensitive

If you use string type, it is case sensitive.

# tags is string[]
post = Post.create(tags: ['food', 'travel', 'Food'])
post.tags
# => ['food', 'travel', 'Food']

If you want case insensitive, you need to use citext

class CreatePosts < ActiveRecord::Migration[8.0]
  enable_extension('citext') unless extensions.include?('citext')

  def change
    create_table :posts do |t|
      t.citext :tags, array: true, default: []

      t.timestamps

      t.index :tags, using: 'gin'
    end
  end
end

You will get the diffent result

# tags is citext[]
post = Post.create(tags: ['food', 'travel', 'Food'])
post.tags
# => ['food', 'travel']

Ransack

You can use with ransack

class Post < ActiveRecord::Base
  def self.ransackable_scopes(_auth_object = nil)
    %i[all_tags]
  end
end

And you can search

Post.ransack(all_tags: 'foold,travel')

License

The gem is available as open source under the terms of the MIT License.

Contact

The project's website is located at https://github.com/emn178/pg_taggable
Author: Chen, Yi-Cyuan ([email protected])