Skip to content

Commit

Permalink
Add soft delete functionality to Avram (#323)
Browse files Browse the repository at this point in the history
  • Loading branch information
paulcsmith committed Mar 16, 2020
1 parent 2828306 commit bb57679
Show file tree
Hide file tree
Showing 8 changed files with 247 additions and 0 deletions.
14 changes: 14 additions & 0 deletions db/migrations/20200316160609_create_soft_deletable_items.cr
@@ -0,0 +1,14 @@
class CreateSoftDeletableItems::V20200316160609 < Avram::Migrator::Migration::V1
def migrate
create table_for(SoftDeletableItem) do
primary_key id : Int64
add_timestamps

add soft_deleted_at : Time?
end
end

def rollback
drop table_for(SoftDeletableItem)
end
end
90 changes: 90 additions & 0 deletions spec/soft_delete_spec.cr
@@ -0,0 +1,90 @@
require "./spec_helper"

class SoftDeletableItemQuery < SoftDeletableItem::BaseQuery
include Avram::SoftDelete::Query
end

describe "Avram soft delete" do
describe "models" do
it "allows soft deleting a record" do
item = SoftDeletableItemBox.create &.kept

item = item.soft_delete

item.soft_deleted_at.should_not be_nil
end

it "allows restoring a soft deleted record" do
item = SoftDeletableItemBox.create &.soft_deleted

item = item.restore

item.soft_deleted_at.should be_nil
end

it "allows checking if a record is soft deleted" do
item = SoftDeletableItemBox.create &.kept
item.soft_deleted?.should be_false

item = item.soft_delete

item.soft_deleted?.should be_true
end
end

describe "queries" do
it "can get only kept records" do
kept_item = SoftDeletableItemBox.create &.kept
SoftDeletableItemBox.create &.soft_deleted

SoftDeletableItemQuery.new.only_soft_deleted.only_kept.results.should eq([
kept_item,
])
end

it "can get only soft deleted records" do
SoftDeletableItemBox.create &.kept
soft_deleted_item = SoftDeletableItemBox.create &.soft_deleted

SoftDeletableItemQuery.new.only_kept.only_soft_deleted.results.should eq([
soft_deleted_item,
])
end

it "can get soft deleted and kept records" do
kept_item = SoftDeletableItemBox.create &.kept
soft_deleted_item = SoftDeletableItemBox.create &.soft_deleted

SoftDeletableItemQuery.new.only_kept.with_soft_deleted.results.should eq([
kept_item,
soft_deleted_item,
])
end

it "can bulk soft delete" do
kept_item = SoftDeletableItemBox.create &.kept
soft_deleted_item = SoftDeletableItemBox.create &.soft_deleted

num_restored = SoftDeletableItemQuery.new.soft_delete

num_restored.should eq(1)
kept_item.reload.soft_deleted?.should be_true
soft_deleted_item.reload.soft_deleted?.should be_true
end

it "can bulk restore" do
kept_item = SoftDeletableItemBox.create &.kept
soft_deleted_item = SoftDeletableItemBox.create &.soft_deleted

num_restored = SoftDeletableItemQuery.new.restore

num_restored.should eq(1)
kept_item.reload.soft_deleted?.should be_false
soft_deleted_item.reload.soft_deleted?.should be_false
end
end
end

private def reload(item)
SoftDeletableItemQuery.find(item.id)
end
9 changes: 9 additions & 0 deletions spec/support/boxes/soft_deletable_item_box.cr
@@ -0,0 +1,9 @@
class SoftDeletableItemBox < BaseBox
def kept
soft_deleted_at nil
end

def soft_deleted
soft_deleted_at Time.utc
end
end
11 changes: 11 additions & 0 deletions spec/support/soft_deletable_item.cr
@@ -0,0 +1,11 @@
class SoftDeletableItem < BaseModel
include Avram::SoftDelete::Model

skip_default_columns

table do
primary_key id : Int64
column soft_deleted_at : Time?
timestamps
end
end
1 change: 1 addition & 0 deletions src/avram.cr
Expand Up @@ -2,6 +2,7 @@ require "dexter"
require "lucky_cli"
require "wordsmith"
require "habitat"
require "blank"
require "./avram/criteria"
require "./avram/type"
require "./avram/table_for"
Expand Down
4 changes: 4 additions & 0 deletions src/avram/save_operation_template.cr
Expand Up @@ -11,6 +11,10 @@ class Avram::SaveOperationTemplate
::{{ type }}::BaseQuery
end

def save_operation_class : ::{{ type }}::SaveOperation.class
::{{ type }}::SaveOperation
end

class ::{{ type }}::SaveOperation < Avram::SaveOperation({{ type }})
{% if primary_key_type.id == UUID.id %}
before_save set_uuid
Expand Down
53 changes: 53 additions & 0 deletions src/avram/soft_delete/model.cr
@@ -0,0 +1,53 @@
# Add methods for soft deleting and restoring an individual record
#
# Include this module in your model, and make sure to add a `soft_deleted_at`
# column to your model. The column type must be `Time?`
#
# ```crystal
# # In a migration
# add soft_deleted_at : Time?
#
# # In your model
# class Article < BaseModel
# include Avram::SoftDelete::Model

# table do
# column soft_deleted_at : Time?
# end
# end
# ```
#
# You should also add the `Avram::SoftDeleteQuery` to your query
#
# ```crystal
# class ArticleQuery < Article::BaseQuery
# include Avram::SoftDelete::Query
# end
# ```
module Avram::SoftDelete::Model
# Soft delete the record
#
# This will set `soft_deleted_at` to the current time (`Time.utc`)
def soft_delete : self
save_operation_class.update!(self, soft_deleted_at: Time.utc)
end

# Restore the record
#
# This will set `soft_deleted_at` to `nil`
def restore : self
save_operation_class.update!(self, soft_deleted_at: nil)
end

abstract def save_operation_class

# Returns true if soft deleted, otherwise false
#
# If the `soft_deleted_at` has a time value the record is "soft deleted".
# If `soft_deleted_at` is `nil` the record has not been deleted yet.
def soft_deleted? : Bool
soft_deleted_at.present?
end

abstract def soft_deleted_at : Time?
end
65 changes: 65 additions & 0 deletions src/avram/soft_delete/query.cr
@@ -0,0 +1,65 @@
# Add methods for querying/updating soft deleted and kept records.
#
# First include the model module in your model: `Avram::SoftDelete::Model`
#
# Then add this module your query
#
# ```crystal
# class ArticleQuery < Article::BaseQuery
# include Avram::SoftDelete::Query
# end
# ```
module Avram::SoftDelete::Query
# Only return kept records
#
# Kept records are considered "kept/not soft deleted" if the
# `soft_deleted_at` column is `nil`
def only_kept
reset_where(&.soft_deleted_at).soft_deleted_at.is_nil
end

# Only return soft deleted records
#
# Soft deleted records are considered "soft deleted" if the
# `soft_deleted_at` column has a non-nil value
def only_soft_deleted
reset_where(&.soft_deleted_at).soft_deleted_at.is_not_nil
end

# Returns all records
#
# This works be removing where clauses for the `soft_deleted_at` column.
# That means you can do `MyQuery.new.only_kept.with_soft_deleted` and you
# will get all records, not just the kept ones.
def with_soft_deleted
reset_where(&.soft_deleted_at)
end

# Bulk soft delete records
#
# ## Example
#
# This will soft delete all `Article` record older than 1 year:
#
# ```crystal
# ArticleQuery.new.created_at.lt(1.year.ago).soft_delete
# ```
def soft_delete
only_kept.update(soft_deleted_at: Time.utc)
end

# Bulk restore records
#
# ## Example
#
# This will restore `Article` records updated in the last week:
#
# ```crystal
# ArticleQuery.new.updated_at.gt(1.week.ago).restore
# ```
def restore : Int64
only_soft_deleted.update(soft_deleted_at: nil)
end

abstract def soft_deleted_at
end

0 comments on commit bb57679

Please sign in to comment.