Rails 关于单表继承删除 super record 的问题

zeekerpro · 2022年04月03日 · 最后由 zeekerpro 回复于 2022年04月04日 · 760 次阅读

场景:teacher 和 people 是单表继承的关系,如下

class People

end

class Teacher < People
  has_and_belongs_to_many :courses :join_table => 'teachers_courses'
end

当删除 people 时:

people = People.first
people.destroy

对应的 Teacher.first 会被删除,但是 teacher 和 course 的关联关系不会被删除。
目前我所知道的有两种办法可以解决这个问题

# 1. 直接使用 Teacher 来删除
teacher = Teacher.first
teacher.destroy


# 2. 将user转换为Teacher
# https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-becomes
people = People.first
teacher = people.becomes(Teacher)
teacher.destroy

这样可以和关联关系一并删除。

但是也有一个问题,如果还有这么一个类

class Student < People
  has_many xxxxxx
end

这样的话要删除一个 people 就太麻烦了,首先要去找所有的子类,然后找到关联关系,然后一个个解除 relationships,最后再删除 record。

有没有现成的轮子或者通用的解决方案可以解决这种单表继承时,删除顶级 record,然后 record 自己检查继承类后删除对应关联的问题?

class People
  alias old_destroy destroy
  def destroy
    return old_destroy if type.blank?

    child = becomes(type.constantize)
    child.destroy
  end
end

可以试试

因为和 courses 关系是在 teacher 建立的,从对象来讲,和 people 没关系,自然删除 people 的时候不会删除 teacher 和 course 的联结表,解决也很简单,把关系放 people 就是了 建议回去看看关联那块的文档 https://ruby-china.github.io/rails-guides/association_basics.html

xeruzo 回复

我只是想让代码更语义一些,毕竟 people 和 course 没有关联,而语义上 teacher 和 course 才有关联。 同理如果还有 driver 也继承了 people,把关系放在 people 上 driver 也会继承到 course 的关系,这种没有理由的。 能兼容语义最好,实在没办法了再全部堆到 people 上。当然再妥协之前想尝试着找找有没有一种优雅的解决方案。 一楼的老哥给的就是一种相对合理的方案了。

zhengpd 回复

这个能解决的现在的问题,不过我还有个比较刁钻的问题,假如还有个 student 类,同样继承了 people。student 和 teacher 都有自己外部的关联关系。

但有一个需求,一个 people 即可以是 student,同时也可以是 teacher,这个时候 type 貌似就失效了。。。

而删除了个 people 的时候它本身作为 teacher 和 student 的关联也都要删除,额。。。

zeekerpro 回复

从语义上讲,那 people 的确和 course 没关系,那删除 people 不删除 teacher 的联结表关系也没问题
“你们找鲁迅和我周树人有什么关系”?
我进一步问下,你实际业务是什么场景?还是只是在学习阶段,为了继承而继承?抽象?
想解决不用其他轮子,你在 people 中的删除回调中加逻辑处理即可,和 1 楼思路类似,有多少要处理的就写多少

class People
after_destroy :clear_all_relations

def clear_all_relations
 # 1楼的代码放这里,不用改写destroy方法
end

end
zeekerpro 回复

根据描述,一方面你追求语义,不期望在父类中建立所有子类的关联关系
另外一方面,你有一个需求,期望在删除父类的时候,能自行清除所有子类里面的联结表数据,不想再额外维护代码(是这个意思吗?如果愿意那 peope 自己写回调解决)
抛开解决方法,这两点我觉得本身就是冲突的,算不上刁钻

irb(main):003:0> Person.first.destroy
  Person Load (0.1ms)  SELECT "people".* FROM "people" ORDER BY "people"."id" ASC LIMIT ?  [["LIMIT", 1]]
  TRANSACTION (0.0ms)  begin transaction
  Teacher::HABTM_Courses Delete All (0.1ms)  DELETE FROM "courses_teachers" WHERE "courses_teachers"."teacher_id" = ?  [["teacher_id", 1]]
  Teacher Destroy (0.0ms)  DELETE FROM "people" WHERE "people"."id" = ?  [["id", 1]]
  TRANSACTION (1.5ms)  commit transaction

Teacher::HABTM_Courses Delete 这一行,我这里测试下来,是没问题的,题主检查一下单表继承有没有配置正确。

验证方式:

irb(main):001:0> Person.first
   (0.3ms)  SELECT sqlite_version(*)
  Person Load (0.1ms)  SELECT "people".* FROM "people" ORDER BY "people"."id" ASC LIMIT ?  [["LIMIT", 1]]
=>
#<Teacher:0x00007f70a00bbba8
 id: 1,
 name: "Miss Cao",
 type: "Teacher",
 created_at: Mon, 04 Apr 2022 02:16:33.331678000 UTC +00:00,
 updated_at: Mon, 04 Apr 2022 02:16:33.331678000 UTC +00:00>

注意 #<Teacher:0x00007f70a00bbba8 ,用 Person.first 获取的对象,会自动变成对应子类的对象, 这是 STI 的精髓所在,是通过 type 字段里的魔法实现的。

Person.rb

class Person < ApplicationRecord
end

Teacher.rb

class Teacher < Person
  has_and_belongs_to_many :courses, join_table: :courses_teachers
end

Course.rb

class Course < ApplicationRecord
  has_and_belongs_to_many :teachers, join_table: :courses_teachers
end

create_people.rb

class CreatePeople < ActiveRecord::Migration[7.0]
  def change
    create_table :people do |t|
      t.string :name
      t.string :type

      t.timestamps
    end
  end
end

create_course.rb

class CreateCourses < ActiveRecord::Migration[7.0]
  def change
    create_table :courses do |t|
      t.string :name

      t.timestamps
    end
  end
end

create_courses_teachers.rb

class CreateCoursesTeachers < ActiveRecord::Migration[7.0]
  def change
    create_join_table :courses, :teachers do |t|
      t.index %i[course_id teacher_id], unique: true
      t.index %i[teacher_id course_id]
    end
  end
end
xinyifly 回复

检查了一下,确实是配置问题,当时建 courses_teachers 表用的是 create_table 方法,并且没加 foreign_key。 这样就好了

add_foreign_key :courses_teachers, :courses, on_delete: :cascade
add_foreign_key :courses_teachers, :peoples, column: :teacher_id, on_delete: :cascade

感谢感谢。

zeekerpro 回复

国内对 STI 的讨论确实非常少,题主对代码的语义化和简练的追求我十分看好。我在工作项目里对 STI 的使用还算比较多,对这方面有问题欢迎多探讨,发贴可以 @我

xinyifly 回复

哈哈,可以的老哥👌

zeekerpro 回复

直接加 on_delete: :cascade 的选项实际是在 DB 层做的级联删除,不是由 rails 在模型层做的删除,rails 日志应该不会体现 DB 层删除子记录。8 楼老哥的日志是能看到子记录和父记录放在同一个事务中的删除,所以你俩的代码其实不一样。(不排除版本差异问题)

不过确实能实现效果,而且这种形式是 gitlab 更倡导的级联删除方式。

people.destroy 多少有点给自己加难度的感觉。如果在父类里边维护子类的关联关系处理,那就超出了父类职责范围,会增加维护 people model 的心智负担(难免有时候改代码会需要考虑对子类的影响,或者子类修改关联的时候还需要记得检查父类)。

用了 STI 让父类职责简单好点,只负责子类通用的关联、属性和行为逻辑。在考虑需要删除子类关系时,people.destroy 实际上就不是子类通用的行为了,因为每个子类的处理逻辑不同。所以最好是直接就完全避免 people.destroy

如果业务上真的就有 People 对应的业务需求实体,比如说“我要删除这个人,不管他是什么身份”,那么考虑做一个单独的 DestroyPeople service 处理后期更好维护。

spike76 回复

我刚刚试了一下,在 db 层做级联删除,rails 日志里面也会体现删除连结表记录的 我的 rails 版本是:6.1.4.4

[1] [backend][development] pry(main)> People.first.destroy
  Question Load (0.7ms)  SELECT "peoples".* FROM "peoples" ORDER BY "people"."id" ASC LIMIT $1  [["LIMIT", 1]]
  TRANSACTION (0.2ms)  BEGIN
  Teachers::HABTM_Courses Destroy (0.8ms)  DELETE FROM "teachers_courses" WHERE "teachers_courses"."teacher_id" = $1  [["teacher_id", 12]]
  Teacher Destroy (1.0ms)  DELETE FROM "peoples" WHERE "peoples"."id" = $1  [["id", 12]]
  TRANSACTION (0.6ms)  COMMIT

不过我另外尝试将 people 的 type 字段去掉的话,就不显示删除连结表记录了,但实际上连结表记录还是会删除的。所以我有个猜测是 rails 层做模型删除是不是和 type 字段有关系。

zeekerpro 回复

我刚刚试了一下,在 db 层做级联删除,rails 日志里面也会体现删除连结表记录的

你贴的删除日志应该不是因为 db 层的级联删除打印的,而是 rails 在模型层做的删除打印的。

我基本复制了 8 楼老哥的代码(rails 6.1),但修改了一些,创建中间表时直接使用 create_table, 且没有使用外键和级联删除的配置,如下:

def change
  create_table :courses_teachers do |t|
    t.belongs_to :course
    t.belongs_to :teacher

    t.timestamps
  end
end

在 destroy person 对象后,依然能自动删除掉中间表。

2.7.2 :002 > Person.first.destroy
  Person Load (0.4ms)  SELECT "people".* FROM "people" ORDER BY "people"."id" ASC LIMIT ?  [["LIMIT", 1]]
  TRANSACTION (0.1ms)  begin transaction
  Teacher::HABTM_Courses Destroy (1.2ms)  DELETE FROM "courses_teachers" WHERE "courses_teachers"."teacher_id" = ?  [["teacher_id", 2]]
  Teacher Destroy (0.2ms)  DELETE FROM "people" WHERE "people"."id" = ?  [["id", 2]]
  TRANSACTION (1.8ms)  commit transaction
 => #<Teacher id: 2, name: "spike", type: "Teacher", created_at: "2022-04-04 10:39:31.226942000 +0000", updated_at: "2022-04-04 10:39:31.226942000 +0000">

也许你需要一个更纯粹的代码例子来排查问题,你最初的代码里应该有其它的差异

zeekerpro 回复

type 字段是 STI 的核心,存放的是子类的名称,同时是保留字段名,不能用于 STI 之外的用途。通过子类 create 的对象会自动设置这个字段的值,不需要手工维护,通过父类 find 到的对象会跟据 type 的值实例化对应子类。Magic happens here~

xinyifly 回复

是的,我删除 type 字段只是用来排查代码问题的一个方法,最后 type 还是会留着的,I like magic ..

需要 登录 后方可回复, 如果你还没有账号请 注册新账号