Find or create a unique related object in idiomatic ActiveRecord?
How do I idiomatically create a new topic if it doesn't exist, or create a link to conferences if a topic exists?
I am working on a project that has conferences and topics. They are both linked to each other through the ConferenceTopic object. Topic titles are unique.
Below is a custom method created in conferences called #find_or_create_topic_with
that takes a name argument. I did this because when I tried to use the #find_or_create_by
name in the conference thread, I couldn't make it second for the log (Rails Console log below)
The end for this kludge is on Github.
class Conference < ActiveRecord::Base
…
def find_or_create_topic_with(name)
if topic = Topic.find_by(name: name)
self.topics.include?(topic) ? topic : self.topics << topic
else
self.topics.create(name: name)
end
end
end
Rails console for find_or_create_by by topic
2.1.2 :001 > Conference.last.topics.find_or_create_by(name: "fun")
Conference Load (1.0ms) SELECT "conferences".* FROM "conferences" ORDER BY "conferences"."id" DESC LIMIT 1
Topic Load (0.5ms) SELECT "topics".* FROM "topics" INNER JOIN "conference_topics" ON "topics"."id" = "conference_topics"."topic_id" WHERE "conference_topics"."conference_id" = $1 AND "topics"."name" = 'fun' LIMIT 1 [["conference_id", 3]]
(0.1ms) BEGIN
Topic Exists (0.4ms) SELECT 1 AS one FROM "topics" WHERE "topics"."name" = 'fun' LIMIT 1
SQL (0.3ms) INSERT INTO "topics" ("created_at", "name", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [["created_at", "2014-08-31 18:57:56.274109"], ["name", "fun"], ["updated_at", "2014-08-31 18:57:56.274109"]]
SQL (0.5ms) INSERT INTO "conference_topics" ("conference_id", "created_at", "topic_id", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["conference_id", 3], ["created_at", "2014-08-31 18:57:56.287526"], ["topic_id", 8], ["updated_at", "2014-08-31 18:57:56.287526"]]
(2.1ms) COMMIT
=> #<Topic id: 8, name: "fun", created_at: "2014-08-31 18:57:56", updated_at: "2014-08-31 18:57:56">
2.1.2 :002 > Conference.first.topics.find_or_create_by(name: "fun")
Conference Load (0.6ms) SELECT "conferences".* FROM "conferences" ORDER BY "conferences"."id" ASC LIMIT 1
Topic Load (0.4ms) SELECT "topics".* FROM "topics" INNER JOIN "conference_topics" ON "topics"."id" = "conference_topics"."topic_id" WHERE "conference_topics"."conference_id" = $1 AND "topics"."name" = 'fun' LIMIT 1 [["conference_id", 1]]
(0.2ms) BEGIN
Topic Exists (0.3ms) SELECT 1 AS one FROM "topics" WHERE "topics"."name" = 'fun' LIMIT 1
(0.2ms) COMMIT
=> #<Topic id: nil, name: "fun", created_at: nil, updated_at: nil>
2.1.2 :003 > Conference.first.topics
Conference Load (0.5ms) SELECT "conferences".* FROM "conferences" ORDER BY "conferences"."id" ASC LIMIT 1
Topic Load (0.3ms) SELECT "topics".* FROM "topics" INNER JOIN "conference_topics" ON "topics"."id" = "conference_topics"."topic_id" WHERE "conference_topics"."conference_id" = $1 [["conference_id", 1]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Topic id: 7, name: "ruby", created_at: "2014-08-31 18:40:40", updated_at: "2014-08-31 18:40:40">]>
Below is the log to search for conferences for topic "fun"
2.1.2 :015 > Topic.find_by(name: "fun").conferences
Topic Load (0.5ms) SELECT "topics".* FROM "topics" WHERE "topics"."name" = 'fun' LIMIT 1
Conference Load (0.3ms) SELECT "conferences".* FROM "conferences" INNER JOIN "conference_topics" ON "conferences"."id" = "conference_topics"."conference_id" WHERE "conference_topics"."topic_id" = $1 [["topic_id", 8]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Conference id: 3, name: "TooLongDidn'tCareConf", location: "Boston", code_of_conduct: false, childcare: false, last_years_attendance: 0, created_at: "2014-08-31 17:47:35", updated_at: "2014-08-31 17:47:35">]>
2.1.2 :016 > Topic.find_by(name: "fun").conferences.count
Topic Load (0.5ms) SELECT "topics".* FROM "topics" WHERE "topics"."name" = 'fun' LIMIT 1
(0.3ms) SELECT COUNT(*) FROM "conferences" INNER JOIN "conference_topics" ON "conferences"."id" = "conference_topics"."conference_id" WHERE "conference_topics"."topic_id" = $1 [["topic_id", 8]]
=> 1
I would like the collection proxy to contain both the first and last conferences, and the counter is 2. With just the help #find_or_create_by
I can only associate it with the conference for which it was first created, not for subsequent conferences which are the topic with the identifier nil.
source to share
Just by reading the code, it looks like you are trying to satisfy three use cases:
- The topic exists and is already connected to the conference. => Don't do anything.
- The topic exists and is not joining the conference. => Add topic to conference topics.
- The topic does not exist. => Create a topic and add it to conference topics.
I think your code is fine. Indeed. There is nothing crude about it, where I would immediately want to rewrite it.
However, I would probably write it like this:
def find_or_create_topic_with(name)
transaction do
topic = Topic.where(name: name).first_or_create!
self.topics.where(topic: topic).first_or_create!
end
rescue
# need to handle uniqueness violations, etc.
end
Some additional notes:
- Where are the tests, bro !?
- To avoid race conditions creating bad data, you probably need a unique topic index for the name (you will never have validates_uniqueness in a model without a unique index to back it up!) And another on the conference topics for topic_id and conference_id.
Feel free to push me to twitter or whatever if that doesn't make sense.
source to share
Sounds like the has_many Topics conference. With that in mind, you should simply use find_or_create_by
in relation .topics
:
topic = my_conference.topics.find_or_create_by(name: "Name")
# topic will now contain either a newly created Topic that is associated with `my_conference`,
# or will be the existing Topic with name "Name" from `my_conference.topics`.
source to share