acts_as_paranoid
TL;DR
- 假删除和恢复(可级联)
default_scope
查询未被假删除的记录,也提供方法查询假删除的记录- 可以仅校验未被假删除的记录的唯一性
- 小心
unscoped
意外
源码分析
需假删除的model,要在定义时标记为
acts_as_paranoid
方法,并且建立deleted_at
字段(可选其他名称)acts_as_paranoid
方法内容如下。其作用是记住“删除标识字段”的名称和类型,并定义默认查询范围为“未作假删除的记录”,重写activerecord的删除方法,增加恢复方法def acts_as_paranoid(options = {})
if !options.is_a?(Hash) && !options.empty?
raise ArgumentError, "Hash expected, got #{options.class.name}"
end
class_attribute :paranoid_configuration
self.paranoid_configuration = {
column: "deleted_at",
column_type: "time",
recover_dependent_associations: true,
dependent_recovery_window: 2.minutes,
double_tap_destroys_fully: true
}
if options[:column_type] == "string"
paranoid_configuration.merge!(deleted_value: "deleted")
end
paranoid_configuration.merge!(options) # user options
unless %w[time boolean string].include? paranoid_configuration[:column_type]
raise ArgumentError, "'time', 'boolean' or 'string' expected" \
" for :column_type option, got #{paranoid_configuration[:column_type]}"
end
return if paranoid?
include ActsAsParanoid::Core
# Magic!
default_scope { where(paranoid_default_scope) }
define_deleted_time_scopes if paranoid_column_type == :time
end
default_scope
、only_deleted
、with_deleted
定义如下def with_deleted
without_paranoid_default_scope
end
def without_paranoid_default_scope
scope = all
scope = scope.unscope(where: paranoid_column)
# Fix problems with unscope group chain
scope = scope.unscoped if scope.to_sql.include? paranoid_default_scope.to_sql
scope
end
def only_deleted
if string_type_with_deleted_value?
without_paranoid_default_scope
.where(paranoid_column_reference => paranoid_configuration[:deleted_value])
elsif boolean_type_not_nullable?
without_paranoid_default_scope.where(paranoid_column_reference => true)
else
without_paranoid_default_scope.where.not(paranoid_column_reference => nil)
end
end
def paranoid_default_scope
if string_type_with_deleted_value?
all.table[paranoid_column].eq(nil)
.or(all.table[paranoid_column].not_eq(paranoid_configuration[:deleted_value]))
elsif boolean_type_not_nullable?
all.table[paranoid_column].eq(false)
else
all.table[paranoid_column].eq(nil)
end
end
如果删除标识是时间字段,则还会给model增加
deleted_inside_time_window
、deleted_after_time
、deleted_before_time
方法def define_deleted_time_scopes
scope :deleted_inside_time_window, lambda { |time, window|
deleted_after_time((time - window)).deleted_before_time((time + window))
}
scope :deleted_after_time, lambda { |time|
only_deleted
.where("#{table_name}.#{paranoid_column} > ?", time)
}
scope :deleted_before_time, lambda { |time|
only_deleted
.where("#{table_name}.#{paranoid_column} < ?", time)
}
end
删除方法
delete_all
、destroy!
、destroy
重定义如下,转化为更新删除标识字段module ClassMethods
def delete_all(conditions = nil)
where(conditions)
.update_all(["#{paranoid_configuration[:column]} = ?", delete_now_value])
end
end
def destroy!
if !deleted?
with_transaction_returning_status do
run_callbacks :destroy do
if persisted?
# Handle composite keys, otherwise we would just use
# `self.class.primary_key.to_sym => self.id`.
self.class
.delete_all([Array(self.class.primary_key), Array(id)].transpose.to_h)
decrement_counters_on_associations
end
@_trigger_destroy_callback = true
stale_paranoid_value
self
end
end
elsif paranoid_configuration[:double_tap_destroys_fully]
destroy_fully!
end
end
alias destroy destroy!
若想执行真删除,可以
delete_all!
或destroy_fully!
,又或者二次destroy!
module ClassMethods
def delete_all!(conditions = nil)
without_paranoid_default_scope.delete_all!(conditions)
end
end
def destroy_fully!
with_transaction_returning_status do
run_callbacks :destroy do
destroy_dependent_associations!
if persisted?
# Handle composite keys, otherwise we would just use
# `self.class.primary_key.to_sym => self.id`.
self.class
.delete_all!([Array(self.class.primary_key), Array(id)].transpose.to_h)
decrement_counters_on_associations
end
stale_paranoid_value
@destroyed = true
freeze
end
end
end
这里
destroy_dependent_associations!
的效果如下(因为dependent会设置
before_destroy
回调,如果子表也是acts_as_paranoid
,那么子表就仅会假删除,所以这里还要再对其二次destroy!
)[8] pry(main)> Magazine.new(issues: [Issue.new])
=> #
[9] pry(main)> _.save
TRANSACTION (0.0ms) begin transaction
Magazine Create (0.3ms) INSERT INTO "magazines" ("name", "found_at", "created_at", "updated_at", "deleted_at") VALUES (?, ?, ?, ?, ?) [["name", nil], ["found_at", nil], ["created_at", "2021-10-25 05:45:16.832457"], ["updated_at", "2021-10-25 05:45:16.832457"], ["deleted_at", nil]]
Issue Create (0.1ms) INSERT INTO "issues" ("issue_no", "magazine_id", "created_at", "updated_at", "deleted_at") VALUES (?, ?, ?, ?, ?) [["issue_no", nil], ["magazine_id", 2], ["created_at", "2021-10-25 05:45:16.833498"], ["updated_at", "2021-10-25 05:45:16.833498"], ["deleted_at", nil]]
TRANSACTION (0.7ms) commit transaction
=> true
[10] pry(main)> Magazine.last.destroy_fully!
Magazine Load (0.1ms) SELECT "magazines".* FROM "magazines" WHERE "magazines"."deleted_at" IS NULL ORDER BY "magazines"."id" DESC LIMIT ? [["LIMIT", 1]]
TRANSACTION (0.0ms) begin transaction
Issue Load (0.1ms) SELECT "issues".* FROM "issues" WHERE "issues"."deleted_at" IS NULL AND "issues"."magazine_id" = ? [["magazine_id", 2]]
Issue Update All (0.3ms) UPDATE "issues" SET deleted_at = '2021-10-25 05:45:25.338030' WHERE "issues"."deleted_at" IS NULL AND "issues"."id" = ? [["id", 2]]
Issue Load (0.0ms) SELECT "issues".* FROM "issues" WHERE "issues"."deleted_at" IS NOT NULL AND "issues"."magazine_id" = ? [["magazine_id", 2]]
Issue Delete All (0.1ms) DELETE FROM "issues" WHERE "issues"."id" = ? [["id", 2]]
Magazine Delete All (0.1ms) DELETE FROM "magazines" WHERE "magazines"."id" = ? [["id", 2]]
TRANSACTION (0.9ms) commit transaction
恢复方法
recover
和recover!
实现如下。可以自定恢复前后的回调,是否级联恢复(默认级联恢复2分钟内的关联记录)
module ClassMethods
def before_recover(method)
set_callback :recover, :before, method
end
def after_recover(method)
set_callback :recover, :after, method
end
end
def recover(options = {})
return if !deleted?
options = {
recursive: self.class.paranoid_configuration[:recover_dependent_associations],
recovery_window: self.class.paranoid_configuration[:dependent_recovery_window],
raise_error: false
}.merge(options)
self.class.transaction do
run_callbacks :recover do
increment_counters_on_associations
deleted_value = paranoid_value
self.paranoid_value = self.class.recovery_value
result = if options[:raise_error]
save!
else
save
end
recover_dependent_associations(deleted_value, options) if options[:recursive]
result
end
end
end
def recover!(options = {})
options[:raise_error] = true
recover(options)
end
def recover_dependent_associations(deleted_value, options)
self.class.dependent_associations.each do |reflection|
recover_dependent_association(reflection, deleted_value, options)
end
end
def recover_dependent_association(reflection, deleted_value, options)
assoc = association(reflection.name)
return unless (klass = assoc.klass).paranoid?
if reflection.belongs_to? && attributes[reflection.association_foreign_key].nil?
return
end
scope = klass.only_deleted.merge(get_association_scope(assoc))
# We can only recover by window if both parent and dependant have a
# paranoid column type of :time.
if self.class.paranoid_column_type == :time && klass.paranoid_column_type == :time
scope = scope.deleted_inside_time_window(deleted_value, options[:recovery_window])
end
recovered = false
scope.each do |object|
object.recover(options)
recovered = true
end
assoc.reload if recovered && reflection.has_one? && assoc.loaded?
end
关于唯一性校验,因activerecord自带的校验对
acts_as_paranoid
的删除标识毫不知情,所以如果你想仅对未作假删除的记录校验唯一性,需要调用validates_as_paranoid
和validates_uniqueness_of_without_deleted :xxx
module ActsAsParanoid
module Validations
def self.included(base)
base.extend ClassMethods
end
class UniquenessWithoutDeletedValidator < ActiveRecord::Validations::UniquenessValidator
private
def build_relation(klass, attribute, value)
super.where(klass.paranoid_default_scope)
end
end
module ClassMethods
def validates_uniqueness_of_without_deleted(*attr_names)
validates_with UniquenessWithoutDeletedValidator, _merge_attributes(attr_names)
end
end
end
end
如果父表
acts_as_paranoid
,而子表不是,但又想子表查到被假删除的父表,可以在belongs_to
定义加上:with_deleted
选项。该选项会给belongs_to
的scope在链式增加with_deleted
的scopemodule ActsAsParanoid
module Associations
def self.included(base)
base.extend ClassMethods
class << base
alias_method :belongs_to_without_deleted, :belongs_to
alias_method :belongs_to, :belongs_to_with_deleted
end
end
module ClassMethods
def belongs_to_with_deleted(target, scope = nil, options = {})
if scope.is_a?(Hash)
options = scope
scope = nil
end
with_deleted = options.delete(:with_deleted)
if with_deleted
if scope
old_scope = scope
scope = proc do |*args|
if old_scope.arity == 0
instance_exec(&old_scope).with_deleted
else
old_scope.call(*args).with_deleted
end
end
else
scope = proc do
if respond_to? :with_deleted
self.with_deleted
else
all
end
end
end
end
result = belongs_to_without_deleted(target, scope, **options)
result.values.last.options[:with_deleted] = with_deleted if with_deleted
result
end
end
end
end