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:
kept
scope (equivalent towhere(deleted_at: nil)
)discarded
scope (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:index
Rails 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
end
There are a few new things here:
- It knows how to parse the table/model name out of the command. In this case it parses
Memberships
out ofadd_discarded_at_to_memberships
- It can accept a
:index
option 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
end
By 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_many
for all members and kept members - We could add a separate
has_many
for 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: :user
Now 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_memberships
We will want to change current_user.channels
to current_user.active_channels
in app/views/layouts/application.html.erb
.
- Add the
discard
gem to your Gemfile:gem 'discard', '~> 1.2'
- Run
bundle install
- Add a
discarded_at
column to yourmemberships
table:bin/rails generate migration add_discarded_at_to_memberships discarded_at:datetime:index
- Run
bin/rails db:migrate
- Add
include Discard::Model
to yourmemberships
model:class Membership < ApplicationRecord include Discard::Model belongs_to :user belongs_to :channel end
- Add a
kept
scope to yourmemberships
in theChannelsController#show
:def show current_user.memberships.kept.find_or_create_by!(channel: @channel) end
- Add
active_memberships
andactive_members
scopes to yourChannel
:has_many :active_memberships, -> { kept }, class_name: "Membership" has_many :active_members, through: :active_memberships, source: :user
- Add
active_memberships
andactive_channels
scopes 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.channels
tocurrent_user.active_channels
andchannel.members
tochannel.active_members
- Done!
https://github.com/dcsil/rails-tutorial-example/commit/22df515bce94c856fe485c0556429e2a160948ec