Right now we have a basic set up with a user and a channel. We can join a channel, but we can't leave it. Let's implement that.
When we enter a channel, we create a membership join table record. We could consider a few options to indicate a user has left a channel:
- We could simply delete this membership
- We could add an "active" column to the membership table and toggle this on/off
- We could soft delete the membership (mark it as deleted using a column) and create new one for new joins
Now let's consider what memberships can tell us:
- We may want to understand how many times the user has joined the channel
- We want to know when the user had last joined a channel, when they left the channel, and how long they have been in the channel
- Something else perhaps?
For the purposes of this exercise, we are going to implement soft deletion as this will help us answer questions like "how many times has this user joined this channel?"
Soft deletion can be implemented in a few ways:
- We can use a column to mark a record as deleted
- We can use a timestamp to mark a record as deleted
- We can use a combination of the above
Discard is a library that can be used to implement soft deletion. It is implemented by @jhawthorn at GitHub and is available as a Gem. It provides some extra functionality for models such as:
keptscope (equivalent towhere(deleted_at: nil))discardedscope (equivalent towhere.not(deleted_at: nil))- And others:
post = Post.first # => #<Post id: 1, ...> post.discard # => true post.discard! # => Discard::RecordNotDiscarded: Failed to discard the record post.discarded? # => true post.undiscarded? # => false post.kept? # => false post.discarded_at # => 2017-04-18 18:49:49 -0700
It is encouraged to read through that Gem's README to understand the nuance in soft deletion. For now, though, let's add the gem to our Gemfile and run bundle install.
gem 'discard', '~> 1.2'NOTE: To take advantage of the discarded column, you cannot call destroy or delete on the record. That will actually delete the record (destroy initiates model callbacks, delete doesn't and just executes SQL). Instead you must call discard or discard! (the latter will raise an error if the record is not discarded).
We now have the gem installed. Let's add a discarded_at column to our memberships table.
Run the following command to generate the migration:
bin/rails generate migration add_discarded_at_to_memberships discarded_at:datetime:indexRails will parse this command and generate the following migration:
class AddDiscardedAtToMemberships < ActiveRecord::Migration[6.1]
def change
add_column :memberships, :discarded_at, :datetime
add_index :memberships, :discarded_at
end
endThere are a few new things here:
- It knows how to parse the table/model name out of the command. In this case it parses
Membershipsout ofadd_discarded_at_to_memberships - It can accept a
:indexoption to add an index to the column
Now we need to add include Discard::Model to our memberships model. It should now look like this:
class Membership < ApplicationRecord
include Discard::Model
belongs_to :user
belongs_to :channel
endBy default discarded records are included in calls like Membership.all, current_user.memberships, etc. This is great as it can be nuanced when you want to include everything, but you may want to exclude discarded records.
For example, when we join a channel we want to create a new record when no existing undiscarded record exists, but want to create one otherwise.
To accomplish this, we can open up our ChannelsController again and add a kept scope to the current_user.memberships cal which should now be:
current_user.memberships.kept.find_or_create_by!(channel: @channel)We also want to consider our calls to channel.members.count in the sidebar. We want to exclude discarded records when we count members.
members is an association on channel through memberships, so we can't add a kept scope to that members. There are a few options:
- We could add a separate
has_manyfor all members and kept members - We could add a separate
has_manyfor kept memberships - We could add a where clause to the members call (
channel.members.where(membership: { discarded_at: nil }))
All things considered, we typically want to avoid having to add where clauses each time - we prefer to add a scope.
In reading the discard README, they also recommend against adding a default scope to your main scope, so in this case let's add a new scope.
Let's start by adding an active_memberships scope to Channel. We can use has_many like before, but give the name active_memberships. Because we are not using a name that matches a column, we also have to provide a class_name. If we added just a class_name, we would have an identical scope to memberships, so we need to add a constraint to that scope: -> { kept }. This will call our kept scope on the memberships table, which is available from the discard gem.
has_many :active_memberships, -> { kept }, class_name: "Membership"We can then add an active_members scope to mirror our members scope.
has_many :active_members, through: :active_memberships, source: :userNow we can change the call from channel.members to channel.active_members and we will get the correct result. We also want to change memberships to active_memberships in the member?(user) method we added before.
We also need to fix channels on the user model. Similar to channel, we get our association through a membership scope. We can add an active_memberships like before and change channels to use that:
has_many :active_memberships, -> { kept }, class_name: "Membership"
has_many :channels, through: :active_membershipsWe will want to change current_user.channels to current_user.active_channels in app/views/layouts/application.html.erb.
- Add the
discardgem to your Gemfile:gem 'discard', '~> 1.2'
- Run
bundle install - Add a
discarded_atcolumn to yourmembershipstable:bin/rails generate migration add_discarded_at_to_memberships discarded_at:datetime:index
- Run
bin/rails db:migrate - Add
include Discard::Modelto yourmembershipsmodel:class Membership < ApplicationRecord include Discard::Model belongs_to :user belongs_to :channel end
- Add a
keptscope to yourmembershipsin theChannelsController#show:def show current_user.memberships.kept.find_or_create_by!(channel: @channel) end
- Add
active_membershipsandactive_membersscopes to yourChannel:has_many :active_memberships, -> { kept }, class_name: "Membership" has_many :active_members, through: :active_memberships, source: :user
- Add
active_membershipsandactive_channelsscopes to yourChannel:has_many :active_memberships, -> { kept }, class_name: "Membership" has_many :active_channels, through: :active_memberships, source: :channel
- In
app/views/layouts/application.html.erb, changecurrent_user.channelstocurrent_user.active_channelsandchannel.memberstochannel.active_members - Done!
https://github.com/dcsil/rails-tutorial-example/commit/22df515bce94c856fe485c0556429e2a160948ec