问题描述
我看到过去有一些关于该主题的问题,但是在Rails 6发布之后,我希望情况有所改变。
目标
具有Role
和Effect
ActiveRecord模型,我想临时设置关联(即应该有role.add_effect(effect)
和role.remove_effect(effect)
方法)以跟踪当前处于活动状态的效果给定角色。请注意,效果寿命超出了这些临时关联,并且在某些时候,许多角色可能具有相同的效果。
假设中间对象(此类模型有术语吗?)
class RoleEffect < ApplicationRecord
belongs_to :role
belongs_to :effect
end
class CreateRoleEffects < ActiveRecord::Migration[6.0]
def change
create_table :role_effects do |t|
t.belongs_to :role,null: false,foreign_key: true
t.references :effect,foreign_key: true
t.integer :counter,default: 0
t.timestamps
end
add_index :role_effects,[:role_id,:effect_id],unique: true
end
end
在sql方面看起来像
INSERT INTO role_effects (role_id,effect_id,counter,created_at,updated_at)
VALUES (:role_id,:effect_id,:delta,:ts,:ts)
ON CONFLICT (role_id,effect_id)
DO UPDATE SET counter = counter + excluded.counter,updated_at = :ts
是问题
(从Rails 6开始)实现此类添加/删除方法的当前正确方法是什么,以使它们成为 atomic 。我看到有upsert方法可用,但从文档(也没有源代码)中,我无法理解如何counter = counter + excluded.counter
来做到这一点。我还看到了使用https://github.com/zdennis/activerecord-import gem的建议,但是拥有仅用于执行单个SQL查询的第三方程序包似乎是过大的选择。
如果upsert
方法的当前实现不适合这种用例,尽管我也无法理解安全地执行它的正确API,但我不介意将其作为自定义SQL查询所有这些值均已安全替换(清除)并最好使用名称而不是匿名“?”提及和订购。
我最初的尝试是
def add_effect(effect)
change_effect_counter(effect,1)
end
def remove_effect(effect)
change_effect_counter(effect,-1)
end
private
def change_effect_counter(effect,delta)
RoleEffect.connection.exec_insert(<<~sql.strip,{role_id: self.id,effect_id: effect.id,delta: delta,ts: Time.Now})
INSERT INTO role_effects (role_id,updated_at)
VALUES (:role_id,:ts)
ON CONFLICT (role_id,effect_id)
DO UPDATE SET counter = counter + :delta,updated_at = :ts
sql
nil
end
但是由于与预期的绑定格式(相对丑陋的数组对)不匹配而导致失败,很不幸。
由于Postgresql和sqlite都支持此类查询,所以我希望解决方案对它们两者都适用。
更新(在第一条评论之后)
我当然有
has_many :role_effects,dependent: :destroy
has_many :effects,through: :role_effects
但这并不提供原子操作来修改关联。
我可以放弃相反的想法,删除唯一性索引并添加诸如role.effects << effect
之类的效果,但是删除一个关联将非常棘手,因为它将需要DELETE ... LIMIT 1
,而默认情况下sqlite中没有此关联(我知道可以使用特殊标志对其进行重新编译,但这对开发环境来说实在是太多了。
或者我可以使用
RoleEffect.find_or_create_by!(role: role,effect: effect).increment!(:counter,delta)
但是我不确定这两个调用是否可以保证在存在并发时的正确行为。即使是-它使用多个查询而不是INSERT ... ON CONFLICT UPDATE
。
解决方法
以防万一,尽管结果不完美,但我还是会发表我的发现(例如,插入不会返回模型对象,这可能是意外的,像role.role_effects这样的缓存可能是过时的。)
class CreateRoleEffects < ActiveRecord::Migration[6.0]
def change
create_table :role_effects do |t|
t.belongs_to :role,null: false,foreign_key: true
t.references :effect,foreign_key: true
t.integer :counter,default: 0
t.timestamps
end
add_index :role_effects,[:role_id,:effect_id],unique: true
end
end
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.exec_custom_insert(sql,vars)
sql = ActiveRecord::Base::sanitize_sql_array([sql,vars])
connection.exec_insert(sql,'SQL')
end
end
class RoleEffect < ApplicationRecord
belongs_to :role
belongs_to :effect
scope :active,-> { where('counter > 0') }
scope :for_role,->(role) { where(role: role) }
def self.increment(role,effect)
change(role,effect,1)
end
def self.decrement(role,-1)
end
private_class_method def self.change(role,delta)
exec_custom_insert(<<~SQL.strip,{role_id: role.id,effect_id: effect.id,delta: delta,ts: Time.now})
INSERT INTO role_effects (role_id,effect_id,counter,created_at,updated_at)
VALUES (:role_id,:effect_id,:delta,:ts,:ts)
ON CONFLICT (role_id,effect_id)
DO UPDATE SET counter = counter + excluded.counter,updated_at = excluded.updated_at
SQL
nil
end
end
class Role < ApplicationRecord
...
has_many :role_effects,dependent: :destroy
has_many :active_effects,-> { RoleEffect.active },through: :role_effects,source: :effect
def add_effect(effect)
RoleEffect.increment(self,effect)
end
def remove_effect(effect)
RoleEffect.decrement(self,effect)
end
end
用法
irb(main):127:0> reload!; r = Role.first; e = Effect.first
Reloading...
(0.0ms) SELECT sqlite_version(*)
Role Load (0.1ms) SELECT "roles".* FROM "roles" ORDER BY "roles"."id" ASC LIMIT ? [["LIMIT",1]]
Effect Load (0.1ms) SELECT "effects".* FROM "effects" ORDER BY "effects"."id" ASC LIMIT ? [["LIMIT",1]]
=> #<Effect id: 1,name: "The Effect",created_at: "2020-09-25 08:42:54",updated_at: "2020-09-25 08:42:54">
irb(main):128:0> r.active_effects
Effect Load (0.1ms) SELECT "effects".* FROM "effects" INNER JOIN "role_effects" ON "effects"."id" = "role_effects"."effect_id" WHERE "role_effects"."role_id" = ? AND (counter > 0) LIMIT ? [["role_id",1],["LIMIT",11]]
=> #<ActiveRecord::Associations::CollectionProxy []>
irb(main):129:0> r.add_effect(e)
SQL (1.2ms) INSERT INTO role_effects (role_id,updated_at)
VALUES (1,1,'2020-09-27 12:16:55.639693','2020-09-27 12:16:55.639693')
ON CONFLICT (role_id,effect_id)
DO UPDATE SET counter = counter + excluded.counter,updated_at = excluded.updated_at
=> nil
irb(main):130:0> r.active_effects
Effect Load (0.3ms) SELECT "effects".* FROM "effects" INNER JOIN "role_effects" ON "effects"."id" = "role_effects"."effect_id" WHERE "role_effects"."role_id" = ? AND (counter > 0) LIMIT ? [["role_id",11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Effect id: 1,updated_at: "2020-09-25 08:42:54">]>
irb(main):131:0> r.remove_effect(e)
SQL (8.1ms) INSERT INTO role_effects (role_id,-1,'2020-09-27 12:17:08.148215','2020-09-27 12:17:08.148215')
ON CONFLICT (role_id,updated_at = excluded.updated_at
=> nil
irb(main):132:0> r.active_effects
Effect Load (0.2ms) SELECT "effects".* FROM "effects" INNER JOIN "role_effects" ON "effects"."id" = "role_effects"."effect_id" WHERE "role_effects"."role_id" = ? AND (counter > 0) LIMIT ? [["role_id",11]]
=> #<ActiveRecord::Associations::CollectionProxy []>