ActiveRecord中的UPSERT规则6

问题描述

我看到过去有一些关于该主题的问题,但是在Rails 6发布之后,我希望情况有所改变。

目标

具有RoleEffect 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

但是由于与预期的绑定格式(相对丑陋的数组对)不匹配而导致失败,很不幸。

由于Postgresqlsqlite都支持此类查询,所以我希望解决方案对它们两者都适用。

更新(在第一条评论之后)

我当然有

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 []>

相关问答

Selenium Web驱动程序和Java。元素在(x,y)点处不可单击。其...
Python-如何使用点“。” 访问字典成员?
Java 字符串是不可变的。到底是什么意思?
Java中的“ final”关键字如何工作?(我仍然可以修改对象。...
“loop:”在Java代码中。这是什么,为什么要编译?
java.lang.ClassNotFoundException:sun.jdbc.odbc.JdbcOdbc...