I see there were some questions on the topic in the past, but after Rails 6 release I hope things have change.
Goal
Having Role
and Effect
ActiveRecord models I would like to temporary set up associations (i.e. there should be role.add_effect(effect)
and role.remove_effect(effect)
methods) to track which effects are currently active for a given role. Note that effects lifetime is beyond those temporary associations and many roles may have the same effect active at some point.
Assuming intermediate object (is there a term for such models?)
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, null: false, foreign_key: true
t.integer :counter, null: false, default: 0
t.timestamps
end
add_index :role_effects, [:role_id, :effect_id], unique: true
end
end
In terms of SQL that would look something like
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
So the question
what is the current (as of Rails 6) proper way to implement such add/remove methods so that they are atomic. I see there is upsert method available but from the documentation (nor the source code) I can't understand how to do that counter = counter + excluded.counter
bit. I also see the recommendations to use https://github.com/zdennis/activerecord-import gem but having a third party package only to execute a single SQL query seems like an overkill.
If current implementation of the upsert
method is not meant for such use case I don't mind having it as a custom SQL query though I also can't understand what would be the proper API to execute it safely with all those values substituted safely (sanitized) and preferably mentioned by name and not by anonymous '?' and ordering.
My initial attempt was
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, 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 + :delta, updated_at = :ts
SQL
nil
end
but it failed miserably due to mismatch with an expected bindings format (rather ugly array of pairs).
Since both PostgreSQL and SQLite do support such queries I expect the solution that would work for both of them.
UPDATE (after first comments)
Of course I do have
has_many :role_effects, dependent: :destroy
has_many :effects, through: :role_effects
But this does not provide an atomic operation to modify the association.
I can abandon the counter idea, remove uniqueness index and add effects like that role.effects << effect
but removing one association would be tricky as it will require DELETE ... LIMIT 1
which is not available in SQLite by default (I know it can be recompiled with a special flag but that is just too much of a requirement for a development env).
Alternatively I could've used
RoleEffect.find_or_create_by!(role: role, effect: effect).increment!(:counter, delta)
But I am not sure if both of this calls guarantee correct behaviour in a presence of concurrency. And even if it is - it uses multiple queries instead of INSERT ... ON CONFLICT UPDATE
.