场景: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
我只是想让代码更语义一些,毕竟 people 和 course 没有关联,而语义上 teacher 和 course 才有关联。 同理如果还有 driver 也继承了 people,把关系放在 people 上 driver 也会继承到 course 的关系,这种没有理由的。 能兼容语义最好,实在没办法了再全部堆到 people 上。当然再妥协之前想尝试着找找有没有一种优雅的解决方案。 一楼的老哥给的就是一种相对合理的方案了。
这个能解决的现在的问题,不过我还有个比较刁钻的问题,假如还有个 student 类,同样继承了 people。student 和 teacher 都有自己外部的关联关系。
但有一个需求,一个 people 即可以是 student,同时也可以是 teacher,这个时候 type 貌似就失效了。。。
而删除了个 people 的时候它本身作为 teacher 和 student 的关联也都要删除,额。。。
从语义上讲,那 people 的确和 course 没关系,那删除 people 不删除 teacher 的联结表关系也没问题
“你们找鲁迅和我周树人有什么关系”?
我进一步问下,你实际业务是什么场景?还是只是在学习阶段,为了继承而继承?抽象?
想解决不用其他轮子,你在 people 中的删除回调中加逻辑处理即可,和 1 楼思路类似,有多少要处理的就写多少
class People
after_destroy :clear_all_relations
def clear_all_relations
# 1楼的代码放这里,不用改写destroy方法
end
end
根据描述,一方面你追求语义,不期望在父类中建立所有子类的关联关系
另外一方面,你有一个需求,期望在删除父类的时候,能自行清除所有子类里面的联结表数据,不想再额外维护代码(是这个意思吗?如果愿意那 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
检查了一下,确实是配置问题,当时建 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
感谢感谢。
国内对 STI 的讨论确实非常少,题主对代码的语义化和简练的追求我十分看好。我在工作项目里对 STI 的使用还算比较多,对这方面有问题欢迎多探讨,发贴可以 @我
。
直接加 on_delete: :cascade 的选项实际是在 DB 层做的级联删除,不是由 rails 在模型层做的删除,rails 日志应该不会体现 DB 层删除子记录。8 楼老哥的日志是能看到子记录和父记录放在同一个事务中的删除,所以你俩的代码其实不一样。(不排除版本差异问题)
不过确实能实现效果,而且这种形式是 gitlab 更倡导的级联删除方式。
people.destroy
多少有点给自己加难度的感觉。如果在父类里边维护子类的关联关系处理,那就超出了父类职责范围,会增加维护 people model 的心智负担(难免有时候改代码会需要考虑对子类的影响,或者子类修改关联的时候还需要记得检查父类)。
用了 STI 让父类职责简单好点,只负责子类通用的关联、属性和行为逻辑。在考虑需要删除子类关系时,people.destroy
实际上就不是子类通用的行为了,因为每个子类的处理逻辑不同。所以最好是直接就完全避免 people.destroy
。
如果业务上真的就有 People 对应的业务需求实体,比如说“我要删除这个人,不管他是什么身份”,那么考虑做一个单独的 DestroyPeople service 处理后期更好维护。
我刚刚试了一下,在 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 字段有关系。
我刚刚试了一下,在 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">
也许你需要一个更纯粹的代码例子来排查问题,你最初的代码里应该有其它的差异
type
字段是 STI 的核心,存放的是子类的名称,同时是保留字段名,不能用于 STI 之外的用途。通过子类 create 的对象会自动设置这个字段的值,不需要手工维护,通过父类 find 到的对象会跟据 type 的值实例化对应子类。Magic happens here~
是的,我删除 type 字段只是用来排查代码问题的一个方法,最后 type 还是会留着的,I like magic ..