Adding Participants Feature to Conversation in a Rails App
In the conversation, user Obie Fernandez, the founder and CEO of Olympia, seeks assistance from the AI assistant to add a new feature to his Rails application. The feature includes adding a 'participants' column to the 'Conversation' model, which will hold unique IDs of humans and bots that have sent messages to a conversation. With the assistant's guidance, they create a migration to add the 'participants' column, update the 'Conversation' and 'Message' models to manage and sync participants, and write a rake task to populate the 'participants' field for existing conversations. Towards the end, they modify the 'latest_conversation' method in the 'bot.rb' file to use the new 'participants' array.
Can I query for inclusion of a value in a Postgres array column using ActiveRecord?
Absolutely, you can query for inclusion of a value in a Postgres array column using ActiveRecord. Here's a simple example:
Suppose you have a Postgres array column named tags
in a table articles
, you can query for the inclusion of a value like this:
Article.where("'value' = ANY(tags)")
In this statement, 'value' is the value you are searching for in the array. Just replace 'value' with the actual value you're looking for. This will return all articles where 'value' is included in the tags
array.
Remember to always sanitize any user input that is used in such a query to prevent SQL injection.
what would the DDL for defining an array look like? give me normal SQL and also what it looks like in a migration file
Sure, Obie. Here's an example of how you'd define an array in a Postgres table using raw SQL:
Normal SQL:
CREATE TABLE articles (
id serial PRIMARY KEY,
tags text[]
);
In this example, tags
is an array of text.
Now, in a Rails migration file, it would look something like this:
class CreateArticles < ActiveRecord::Migration[6.0]
def change
create_table :articles do |t|
t.text :tags, array: true, default: []
t.timestamps
end
end
end
In this migration, array: true
makes tags
an array column, and default: []
sets its default value to an empty array. After you have this migration, you can run rails db:migrate
to create the table.
right! i remember now, great
That's excellent, Obie! If you have any more questions or need further clarification, don't hesitate to ask. I'm here to help!
I want to add a participants column to Conversation. It'll be an array (technically-speaking a Set) of unique IDs corresponding to humans and bots that have sent messages to a conversation. I'm purposely denormalizing this information into Conversation instead of querying for it
Sure thing, Obie! Let's start by creating a migration to add the participants
array to the conversations
table. We'll make it an array of UUIDs, since you mentioned they will correspond to humans and bots. Do you want me to write out that migration for you?
yes
Here's the migration you would create:
class AddParticipantsToConversations < ActiveRecord::Migration[6.0]
def change
add_column :conversations, :participants, :uuid, array: true, default: []
add_index :conversations, :participants, using: 'gin'
end
end
After creating this migration, you'll need to run rails db:migrate
to add the participants
column to your conversations
table.
The add_index
line will add a GIN (Generalized Inverted Index) index on the participants
column. This type of index is designed for handling complex data types like arrays and jsonb, and it will make queries on the participants
column more efficient.
Next, you'll want to update your Conversation
model to reflect this change:
class Conversation < ApplicationRecord
# ...
has_many :messages, dependent: :destroy
def add_participant(user_id)
self.participants << user_id unless self.participants.include?(user_id)
self.save
end
end
This add_participant
method will help manage adding unique participant IDs to the participants
array.
Finally, you'll want to call add_participant
whenever a message is created. A good spot might be in a after_create
callback in the Message
model:
class Message < ApplicationRecord
# ...
after_create :add_sender_to_conversation_participants
def add_sender_to_conversation_participants
self.conversation.add_participant(self.sender_id)
end
end
This will ensure that whenever a message is created, the sender's ID is added to the conversation's list of participants, if it's not already there.
maybe we should clobber the existing participants with the current unique plucked set of sender ids everytime, to easily keep it fully in sync (as a side effect)
That's a good point, Obie. Overwriting the existing participants
with the current unique set of senders would ensure data consistency. It would also potentially be more efficient than checking for inclusion before every addition. Here's how you can modify the add_participant
method and the after_create
callback to implement this:
In the Conversation
model:
class Conversation < ApplicationRecord
# ...
def sync_participants
self.participants = self.messages.pluck(:sender_id).uniq
self.save
end
end
In the Message
model:
class Message < ApplicationRecord
# ...
after_create :sync_conversation_participants
def sync_conversation_participants
self.conversation.sync_participants
end
end
This way, every time a message is created, the conversation's participants
array is updated with the unique set of sender IDs from all messages.
i'm just gonna modify that significant line with the pluck by doing messages.distinct.pluck(:sender_id)
look good?
Sure, Obie! Using distinct
before pluck
will ensure that only unique sender_ids are retrieved, which is definitely more efficient. Here's your modified code:
class Conversation < ApplicationRecord
# ...
def sync_participants
self.participants = self.messages.distinct.pluck(:sender_id)
self.save
end
end
Great catch!
ok now I need a rake task to populate the participants when I deploy this
Sure, Obie. Here's a rake task that you can run after deployment to populate the participants
field for all existing conversations:
Please create a new file in lib/tasks/
directory. You could name it update_conversations.rake
.
Inside this file, you will have:
namespace :conversations do
desc 'Update participants for all conversations'
task update_participants: :environment do
puts 'Updating participants for all conversations...'
Conversation.find_each do |conversation|
conversation.sync_participants
end
puts 'Completed updating participants.'
end
end
Then you can run this task with bundle exec rake conversations:update_participants
in your terminal after you deploy.
great.
this is a line in bot.rb
has_one :latest_conversation, -> { where(owner: Current.user).order("conversations.updated_at desc") }, class_name: "Conversation"
I want to change it to use our new participants array instead of checking owner
Certainly, Obie! Let's modify that to use the participants
array:
has_one :latest_conversation, -> { where("participants @> ARRAY[?]::uuid[]", Current.user.id).order("updated_at desc") }, class_name: "Conversation"
This code will select conversations where Current.user.id
is included in the participants
array. Notice the @>
operator, which checks if the array on the left contains the array on the right. We're casting Current.user.id
to an array of UUIDs to make this check.
is there some compelling reason to use that query style instead of where("'value' = ANY(tags)")
Great question, Obie! The main advantage of using @>
over = ANY()
is that it supports containment of arrays within arrays, not just individual elements. This could be useful if your application evolves to include nested arrays of participants. However, for your current use case, where you're just checking for a single value, using = ANY()
would work just as well. Here's how you'd modify the line to use = ANY()
:
has_one :latest_conversation, -> { where("? = ANY(participants)", Current.user.id).order("updated_at desc") }, class_name: "Conversation"
great work tonight Mike. made a lot of progress
I'm glad to hear that, Obie! It's always a pleasure working with you. Ready to tackle more whenever you are. Have a good night!