acts-as-taggable-on is a commonly used gem in Rails for handling tagging functionality. However, similar features can also be implemented using PostgreSQL’s special Array type. For example, the acts-as-taggable-array-on gem utilizes PostgreSQL Arrays to achieve this. This article will compare the following solutions:
acts-as-taggable-on acts-as-taggable-array-on pg_taggable Scenario Suppose there are two models: Post and User. A Post has a tags attribute and belongs to a User. Common functionalities might include:
Searching for posts by tags. Retrieving the tags of a specific user. Counting the occurrences of a user’s tags (e.g., for a tag cloud). Features acts-as-taggable-on Setup acts-as-taggable-on uses two additional tables to store tags. Once the database tables are set up, you only need to configure it in the model.
1 2 3 4 5 class Post < ApplicationRecord belongs_to :user acts_as_taggable_on :tags end
To enable retrieving tags by users, the User model needs to be additionally configured as a tagger.
1 2 3 4 5 class User < ApplicationRecord has_many :posts acts_as_tagger end
Note that if the configuration is added later, the existing data will not have any tagger records.
If there’s no need to record the tagger, you can use it directly like this:
1 2 3 4 post.tag_list = 'food, travel, technology' post.save post.tag_list
However, after using acts_as_tagger, you must use it like this:
1 2 3 4 5 user.tag(post, with: 'food, travel, technology' , on: :tags ) post.owner_tags_on(user, :tags ) post.all_tags_list
The logic is a bit complex, since the tagger feature is actually intended for a different purpose. Here, it’s being used simply to distinguish tags by user.
Query Use tagged_with to perform searches with various conditions.
1 2 3 4 5 6 7 8 9 Post .tagged_with(%w[food travel technology] , on: :tags )Post .tagged_with(%w[food travel technology] , on: :tags , match_all: true )Post .tagged_with(%w[food travel technology] , on: :tags , any: true )Post .tagged_with(%w[food travel technology] , on: :tags , exclude: true )Post .tagged_with(%w[food travel] , on: :tags ).tagged_with(%w[technology] , on: :tags , exclude: true )Post .tagged_with(%w[food] , on: :tags , wild: :suffix )Post .tagged_with(%w[food] , on: :tags , wild: :prefix )
Ransack Search The official Ransack documentation explains how to search records using acts-as-taggable-on . However, in the latest version of Ransack, there’s a bug that we can’t use it directly. Some additional setup is required, in the Post model, add:
1 2 3 4 5 6 7 8 9 10 11 def self .ransackable_attributes(auth_object = nil ) %w[tags_name] end def self .ransackable_associations(auth_object = nil ) %w[tags] end ransacker :tags_name do |parent | Arel::Nodes::SqlLiteral .new("tags.name" ) end
Then we can query it just like a regular field.
1 2 Post .joins(:tags ).ransack(tags_name_eq: 'tag' ).result(distinct: true )Post .joins(:tags ).ransack(tags_name_in: %w[tag1 tag2] ).result(distinct: true )
Tag List 1 2 3 4 5 6 7 8 9 10 user.owned_tags user.owned_tags.where(taggings: { taggable_type: 'Post' }) user.owned_tags.pluck(:name )
Counting 1 2 3 4 5 6 7 8 9 10 11 Post .all.tag_counts_on(:tags )Post .where(user_id: 1 ).tag_counts_on(:tags )Post .where(user_id: 1 ).tag_counts_on(:tags ).each do |record | end
acts-as-taggable-array-on Setup Since it’s using PostgreSQL Array, you need to create the relevant columns and indexes.
1 2 3 4 5 6 7 8 9 create_table :posts do |t | t.bigint :user_id t.string :tags , array: true , default: [] t.timestamps t.index :user_id t.index :tags , using: "gin" end
Next, add the following in the model:
1 2 3 4 5 class Post < ApplicationRecord belongs_to :user taggable_array :tags end
When using the string type, it is case-sensitive. If you want it to be case-insensitive, you should use citext instead.
You can treat it like a regular array.
1 2 3 4 5 post.tags = %w[food travel technology] post.save post.tags
It will not remove duplicate items.
1 2 3 4 5 post.tags = %w[food food] post.save post.tags
Query It will generate the corresponding class methods.
with_any_#{tag_name}, equal to tagged_with(any: true)with_all_#{tag_name}, equal to tagged_with with default optionswithout_any_#{tag_name}, equal to tagged_with(exclude: true)without_all_#{tag_name}, acts-as-taggable-on has no corresponding functionality1 2 3 4 5 Post .with_any_tags(%w[food travel technology] )Post .with_all_tags(%w[food travel technology] )Post .without_any_tags(%w[food travel technology] )Post .without_all_tags(%w[food travel technology] )Post .with_all_tags(%w[food travel] ).without_any_tags(%w[technology] )
There is no match_all or pattern matching.
Ransack Search Scopes can be used directly for searching. Add the following to the Post model:
1 2 3 def self .ransackable_scopes(auth_object = nil ) %w[with_all_tags] end
You can then search directly.
1 2 Post .ransack(with_all_tags: 'tag' ).resultPost .ransack(with_all_tags: 'tag1,tag2' ).result
Tag List It will generate the corresponding class methods.
1 2 3 4 5 6 7 8 Post .all_tagsPost .all_tags { where(user_id: 1 ) }
You can see that the usage for condition filtering is not easy to use. Using this, you can manually implement pattern matching:
1 2 tags = Post .all_tags.select { |t | t.start_with?('food' ) } Post .without_any_tags(tags)
Counting It will generate the corresponding class methods.
1 2 3 4 5 6 7 8 Post .tags_cloudPost .tags_cloud { where(user_id: 1 ) }
Similarly, the usage for condition filtering is not easy to use.
pg_taggable Setup Similar to acts-as-taggable-array-on, the migration needs to add the columns, as mentioned above. In the model, you should use taggable instead.
1 2 3 4 5 class Post < ApplicationRecord belongs_to :user taggable :tags end
Similar to acts-as-taggable-array-on, you can treat it like a regular array. Refer to the above.
But by default, it will remove duplicate items.
1 2 3 4 5 post.tags = %w[food food] post.save post.tags
If you want it to allow duplicate items, you can change the settings:
1 taggable :tags , unique: false
Query Using the where method directly for queries, it will generate the following query:
any_#{tag_name}, equal to tagged_with(any: true)all_#{tag_name}, equal to tagged_with with default options#{tag_name}_in, acts-as-taggable-on has no corresponding functionality#{tag_name}_eq, equal to tagged_with(match_all: true)1 2 3 4 5 Post .where(any_tags: %w[food travel technology] )Post .where(all_tags: %w[food travel technology] )Post .where(tags_in: %w[food travel technology] )Post .where(tags_eq: %w[food travel technology] )Post .where(all_tags: %w[food travel] ).where.not (any_tags: %w[travel] )
You can also use not directly.
1 2 Post .where.not (any_tags: %w[food travel technology] )
It doesn’t support pattern matching, but it offers some different search methods.
Ransack Search Scopes can be used directly for searching. Add the following to the Post model:
1 2 3 def self .ransackable_scopes(auth_object = nil ) %w[all_tags] end
You can then search directly.
1 2 Post .ransack(all_tags: 'tag' ).resultPost .ransack(all_tags: 'tag1,tag2' ).result
1 2 3 4 5 6 7 8 9 10 11 Post .tagsPost .tags.distinct.pluck(:tag )Post .uniq_tagsPost .where(user_id: 1 ).uniq_tags
Using this, you can manually implement pattern matching:
1 2 tags = Post .tags.where("tag LIKE ?" , "food%" ).distinct.pluck(:tag ) Post .where(any_tags: tags)
Counting It will generate the corresponding class methods.
1 2 3 4 5 6 Post .count_tagsPost .where(user_id: 1 ).count_tags
Benchmark Next, let’s test the performance. I created a benchmark project , and the results are approximately as follows (reorganized and formatted):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 --------------------------------- benchmark:create acts-as-taggable-on 0.021 (± 0.0%) i/s (47.50 s/i) - 1.000 in 47.499407s acts-as-taggable-array-on 0.487 (± 0.0%) i/s (2.05 s/i) - 3.000 in 6.161091s pg_taggable 0.484 (± 0.0%) i/s (2.07 s/i) - 3.000 in 6.197196s --------------------------------- benchmark:as_json acts-as-taggable-on 0.055 (± 0.0%) i/s (18.21 s/i) - 1.000 in 18.208944s acts-as-taggable-array-on 0.983 (± 0.0%) i/s (1.02 s/i) - 6.000 in 6.152531s pg_taggable 1.034 (± 0.0%) i/s (967.47 ms/i) - 6.000 in 5.809804s --------------------------------- benchmark:owned_tags acts-as-taggable-on 2.471 (±40.5%) i/s (404.74 ms/i) - 12.000 in 5.234341s acts-as-taggable-array-on 3.249 (± 0.0%) i/s (307.82 ms/i) - 16.000 in 5.031208s pg_taggable 3.323 (± 0.0%) i/s (300.89 ms/i) - 17.000 in 5.165599s --------------------------------- benchmark:count acts-as-taggable-on 1.070 (± 0.0%) i/s (934.87 ms/i) - 6.000 in 5.621106s acts-as-taggable-array-on 3.061 (± 0.0%) i/s (326.66 ms/i) - 16.000 in 5.250155s pg_taggable 3.273 (± 0.0%) i/s (305.55 ms/i) - 17.000 in 5.215984s --------------------------------- benchmark:like acts-as-taggable-on 6.360 (±78.6%) i/s (157.22 ms/i) - 26.000 in 5.050598s acts-as-taggable-array-on 0.807 (± 0.0%) i/s (1.24 s/i) - 5.000 in 6.217276s pg_taggable 0.928 (± 0.0%) i/s (1.08 s/i) - 5.000 in 5.392569s --------------------------------- benchmark:all_tags[1] acts-as-taggable-on 4.025 (±24.8%) i/s (248.46 ms/i) - 20.000 in 5.054010s acts-as-taggable-array-on 10.377 (±19.3%) i/s (96.37 ms/i) - 50.000 in 5.070872s pg_taggable 10.658 (±18.8%) i/s (93.83 ms/i) - 51.000 in 5.045711s --------------------------------- benchmark:all_tags[5] acts-as-taggable-on 0.463 (± 0.0%) i/s (2.16 s/i) - 3.000 in 6.480544s acts-as-taggable-array-on 15.917 (±50.3%) i/s (62.83 ms/i) - 66.000 in 5.082511s pg_taggable 11.104 (±27.0%) i/s (90.06 ms/i) - 52.000 in 5.009450s --------------------------------- benchmark:all_tags[10] acts-as-taggable-on 0.117 (± 0.0%) i/s (8.53 s/i) - 1.000 in 8.533582s acts-as-taggable-array-on 17.848 (±50.4%) i/s (56.03 ms/i) - 76.000 in 5.207719s pg_taggable 11.504 (±17.4%) i/s (86.93 ms/i) - 56.000 in 5.007423s --------------------------------- benchmark:any_tags[1] acts-as-taggable-on 3.362 (± 0.0%) i/s (297.47 ms/i) - 17.000 in 5.135870s acts-as-taggable-array-on 5.745 (±17.4%) i/s (174.05 ms/i) - 28.000 in 5.024439s pg_taggable 6.228 (±32.1%) i/s (160.56 ms/i) - 30.000 in 5.149333s --------------------------------- benchmark:any_tags[5] acts-as-taggable-on 2.637 (±37.9%) i/s (379.24 ms/i) - 13.000 in 5.161145s acts-as-taggable-array-on 1.807 (± 0.0%) i/s (553.50 ms/i) - 10.000 in 5.558059s pg_taggable 1.851 (± 0.0%) i/s (540.14 ms/i) - 10.000 in 5.467386s --------------------------------- benchmark:any_tags[10] acts-as-taggable-on 2.171 (±46.1%) i/s (460.58 ms/i) - 11.000 in 5.311140s acts-as-taggable-array-on 1.490 (± 0.0%) i/s (671.32 ms/i) - 8.000 in 5.378806s pg_taggable 1.509 (± 0.0%) i/s (662.76 ms/i) - 8.000 in 5.308514s --------------------------------- benchmark:exclude_tags[1] acts-as-taggable-on 2.572 (± 0.0%) i/s (388.73 ms/i) - 13.000 in 5.167899s acts-as-taggable-array-on 1.269 (± 0.0%) i/s (787.84 ms/i) - 7.000 in 5.544178s pg_taggable 1.323 (± 0.0%) i/s (755.66 ms/i) - 7.000 in 5.306183s --------------------------------- benchmark:exclude_tags[5] acts-as-taggable-on 2.077 (± 0.0%) i/s (481.49 ms/i) - 11.000 in 5.325972s acts-as-taggable-array-on 1.112 (± 0.0%) i/s (899.34 ms/i) - 6.000 in 5.440142s pg_taggable 1.171 (± 0.0%) i/s (854.11 ms/i) - 6.000 in 5.129889s --------------------------------- benchmark:exclude_tags[10] acts-as-taggable-on 1.956 (±51.1%) i/s (511.35 ms/i) - 10.000 in 5.371704s acts-as-taggable-array-on 0.998 (± 0.0%) i/s (1.00 s/i) - 5.000 in 5.023394s pg_taggable 1.014 (± 0.0%) i/s (985.74 ms/i) - 6.000 in 5.918865s --------------------------------- benchmark:match_all_tags[1] acts-as-taggable-on 1.495 (± 0.0%) i/s (668.81 ms/i) - 8.000 in 5.379761s pg_taggable 7.993 (±37.5%) i/s (125.11 ms/i) - 37.000 in 5.061658s --------------------------------- benchmark:match_all_tags[5] acts-as-taggable-on 0.396 (± 0.0%) i/s (2.53 s/i) - 2.000 in 5.051440s pg_taggable 17.332 (±69.2%) i/s (57.70 ms/i) - 54.000 in 5.137717s --------------------------------- benchmark:match_all_tags[10] acts-as-taggable-on 0.112 (± 0.0%) i/s (8.90 s/i) - 1.000 in 8.897128s pg_taggable 14.906 (±73.8%) i/s (67.09 ms/i) - 48.000 in 5.091181s ---------------------------------
It can be seen that PostgreSQL Array performs better in most cases, while acts-as-taggable-on has lower performance in the following areas:
Adding new tags Reading tags Default search mode (all_tags) match_all Counting The first three are common use cases.
acts-as-taggable-on performs better in less commonly used pattern matching scenarios.
Conclusions We compare in a table:
Item acts-as-taggable-on acts-as-taggable-array-on pg_taggable Features O X O Easy to use △ △ O Performance X O O General-purpose O X X Ransack Support △ O O
The latter two are only available when using PostgreSQL. If you’re certain about using PostgreSQL, pg_taggable could be a good option. At the beginning, I intended to use acts-as-taggable-array-on, but its functionality was not very user-friendly, so I ended up writing pg_taggable by myself.