Brad West
Brad West

Reputation: 969

Add Existing Tag to Article in Rails 6

I'm trying to add existing Tags to Articles.

My models:

# app/models/user.rb
class User < ApplicationRecord
  has_many :articles, dependent: :destroy
  has_many :tags, dependent: :destroy
end

# app/models/article.rb
class Article < ApplicationRecord
  belongs_to :user
  has_many :tags
end

# app/models/tag.rb
class Tag < ApplicationRecord
  belongs_to :user
  belongs_to :article
  has_many :articles
  validates :name, presence: true, uniqueness: { scope: :user_id }
end

By using the following in the console, I'm able to add a tag to an article.

> a = Article.last
> a.tags.create(name: "unread", user_id: "1")
> a.tags
  Tag Load (0.3ms)  SELECT "tags".* FROM "tags" WHERE "tags"."article_id" = ?  [["article_id", 29]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Tag id: 6, name: "unread", user_id: 1, article_id: 29, created_at: "2020-12-28 16:05:36", updated_at: "2020-12-28 16:05:36", permalink: "unread">]>

If I try to add this same tag to different article using the same .create, I get a rollback error.

> a = Article.first
> a.tags.create(name: "unread", user_id: "1")
   (0.0ms)  begin transaction
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Tag Exists? (0.1ms)  SELECT 1 AS one FROM "tags" WHERE "tags"."name" = ? AND "tags"."user_id" = ? LIMIT ?  [["name", "unread"], ["user_id", 1], ["LIMIT", 1]]
   (0.0ms)  rollback transaction

Thinking the problem is the .create since that tag already exists, I tried this first_or_create, but that also errored.

> a.tags.first_or_create(name: "unread", user_id: "1")
  Tag Load (0.1ms)  SELECT "tags".* FROM "tags" WHERE "tags"."article_id" = ? ORDER BY "tags"."id" ASC LIMIT ?  [["article_id", 52], ["LIMIT", 1]]
   (0.0ms)  begin transaction
  Tag Exists? (0.1ms)  SELECT 1 AS one FROM "tags" WHERE "tags"."name" = ? AND "tags"."user_id" = ? LIMIT ?  [["name", "unread"], ["user_id", 1], ["LIMIT", 1]]
   (0.3ms)  rollback transaction

How do I add an existing tag to an article?

Edit:

I've added a join table as prescribed by Max. This has allowed me to save tags to articles via the console.

> t = Tag.where(name: "rails", user_id: "1").first_or_create
  Tag Load (0.6ms)  SELECT "tags".* FROM "tags" WHERE "tags"."name" = ? AND "tags"."user_id" = ? ORDER BY "tags"."id" ASC LIMIT ?  [["name", "rails"], ["user_id", 1], ["LIMIT", 1]]
=> #<Tag id: 8, name: "rails", user_id: 1, created_at: "2020-12-28 21:21:25", updated_at: "2020-12-28 21:21:25", permalink: "rails">
> a.tags << t
   (0.0ms)  begin transaction
  Article Tag Exists? (0.1ms)  SELECT 1 AS one FROM "article_tags" WHERE "article_tags"."tag_id" = ? AND "article_tags"."article_id" = ? LIMIT ?  [["tag_id", 8], ["article_id", 29], ["LIMIT", 1]]
  Article Tag Create (0.2ms)  INSERT INTO "article_tags" ("article_id", "tag_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["article_id", 29], ["tag_id", 8], ["created_at", "2020-12-28 21:22:13.567187"], ["updated_at", "2020-12-28 21:22:13.567187"]]
   (17.9ms)  commit transaction
  Tag Load (0.1ms)  SELECT "tags".* FROM "tags" INNER JOIN "article_tags" ON "tags"."id" = "article_tags"."tag_id" WHERE "article_tags"."article_id" = ? LIMIT ?  [["article_id", 29], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Tag id: 6, name: "unread", user_id: 1, created_at: "2020-12-28 16:05:36", updated_at: "2020-12-28 16:05:36", permalink: "unread">, #<Tag id: 8, name: "rails", user_id: 1, created_at: "2020-12-28 21:21:25", updated_at: "2020-12-28 21:21:25", permalink: "rails">]>

I'm still unclear how to save this from the view.

I have a form in app/views/articles/edit.html.erb:

<%= form_with(model: @article, local: true) do |form| %>
  <%= form.label :tag_list, "Tags, separated by comma" %>
  <%= form.text_field :tag_list %>
  <%= form.submit "Update Tags" %>
<% end %>

In the Articles Controller I assume I'd want the following.

def edit
  @article.tags << Tag.where(name: "unread", user_id: "1").first_or_create
  @article.save!
end

...or perhaps I need to loop those tags in the model? Something like this:

# app/models/article.rb
def tag_list
  tags.map(&:name).join(", ")
end

def tag_list=(names)
  self.tags = names.split(",").map do |name|
    Tag.where(name: name.strip, user_id: current_user.id).first_or_create!
  end
end
# app/controllers/articles_controller.rb
def edit
  @article.tags << tag_list
  @article.save!
end

But this isn't working. How do I update the tags from a view?

Edit:

I've decided not to update tags by form, so I've marked Max's answer as the solution since he pointed me to the Join Tables which got me on the right track.

If you wish to update this for posterity, I'm sure they will appreciate it.

Upvotes: 0

Views: 91

Answers (1)

max
max

Reputation: 102036

If you want a tag to ever belong to more then a single article you need a join table:

class Article < ApplicationRecord
  has_many :article_tags
  has_many :tags, through: :article_tags
end

# rails g model article_tag article:references tag:references
# make sure to add a unique index on article_id and tag_id
class ArticleTag < ApplicationRecord
  belongs_to :article
  belongs_to :tag
  validates_uniqueness_of :tag_id, scope: :article_id
end

class Tag < ApplicationRecord
  has_many :article_tags
  has_many :articles, through: :article_tags
end

This creates a many to many association between the two tables (an article can have multiple tags, and a tag can belong to multiple articles). This can also be done through has_and_belongs_to_many which does not have a join model but has limited flexibility.

If you want to add an existing tag(s) to an article you can use the shovel operator:

a = Article.last
a.tags << Tag.first
a.tags << Tag.second

But usually you use the tag_ids= setter to set the association from an array of ids:

class ArticlesController
  def create
    @article = Article.new(article_params)
    # ...
  end

  private
  def article_params
    require(:article)
      .permit(:title, :body, tag_ids: [])
  end
end
<%= form_with(model: @article) do |f| %>
  # ...
  <%= f.collection_checkboxes :tag_ids, Tag.all, :id, :name %>
<% end %>

tag_ids: [] permits an array of ids.

This will automatically add/remove rows in the join table depending on what the user checks.

Upvotes: 1

Related Questions