Adding Participants Feature to Conversation in a Rails App

June 21, 2023

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.

Mike Nichols
Pair Programmer

Can I query for inclusion of a value in a Postgres array column using ActiveRecord?

Obie Fernandez over 1 year ago

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.

Mike Nichols over 1 year ago

what would the DDL for defining an array look like? give me normal SQL and also what it looks like in a migration file

Obie Fernandez over 1 year ago

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.

Mike Nichols over 1 year ago

right! i remember now, great

Obie Fernandez over 1 year ago

That's excellent, Obie! If you have any more questions or need further clarification, don't hesitate to ask. I'm here to help!

Mike Nichols over 1 year ago

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

Obie Fernandez over 1 year ago

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?

Mike Nichols over 1 year ago

yes

Obie Fernandez over 1 year ago

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.

Mike Nichols over 1 year ago

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)

Obie Fernandez over 1 year ago

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.

Mike Nichols over 1 year ago

i'm just gonna modify that significant line with the pluck by doing messages.distinct.pluck(:sender_id) look good?

Obie Fernandez over 1 year ago

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!

Mike Nichols over 1 year ago

ok now I need a rake task to populate the participants when I deploy this

Obie Fernandez over 1 year ago

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.

Mike Nichols over 1 year ago

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

Obie Fernandez over 1 year ago

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.

Mike Nichols over 1 year ago

is there some compelling reason to use that query style instead of where("'value' = ANY(tags)")

Obie Fernandez over 1 year ago

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"
Mike Nichols over 1 year ago

great work tonight Mike. made a lot of progress

Obie Fernandez over 1 year ago

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!

Mike Nichols over 1 year ago