Skip to content

Instantly share code, notes, and snippets.

@brendon
Last active June 25, 2023 21:42
Show Gist options
  • Save brendon/d6cfd60cb5e70dc77a15a2476f04d279 to your computer and use it in GitHub Desktop.
Save brendon/d6cfd60cb5e70dc77a15a2476f04d279 to your computer and use it in GitHub Desktop.
A simple Rails Concern for maintaining an arbitrary rank in a model based on a parent scope
# An example migration to add your position column to an existing table
class AddPositionToThings < ActiveRecord::Migration[6.1]
def change
add_column :things, :position, :integer, null: false
ParentThing.all.each do |parent_thing|
parent_thing.things.order(:updated_at).each.with_index(1) do |thing, index|
thing.update_column :position, index
end
end
add_index :things, [:parent_thing_id, :position], unique: true
end
end
class ParentThing
has_many :things, -> { order(:position) }, dependent: :destroy
end
module Positioning
extend ActiveSupport::Concern
included do
cattr_accessor :position_scope
attribute :prior_id, :integer
belongs_to :prior, -> (positioned) { where(position_scope.to_h { |scope_component|
[scope_component, positioned.send(scope_component)]
}) }, class_name: self.base_class.name, optional: true
attribute :subsequent_id, :integer
belongs_to :subsequent, -> (positioned) { where(position_scope.to_h { |scope_component|
[scope_component, positioned.send(scope_component)]
}) }, class_name: self.base_class.name, optional: true
before_save :automatically_assign_position
before_save :shuffle_positions, if: :position_changed?
after_destroy :fill_gap, unless: :destroyed_via_position_scope?
end
class_methods do
def positioned_on(*scope)
self.position_scope = scope.map do |scope_component|
reflections[scope_component.to_s] ? reflections[scope_component.to_s].foreign_key : scope_component
end
end
end
def position=(position)
position_will_change!
super
end
def prior
super || positioning_scope.where(position: position - 1).first
end
def subsequent
super || positioning_scope.where(position: position + 1).first
end
def saved_change_to_positioning_scope?
position_scope.any? do |scope_component|
saved_change_to_attribute?(scope_component)
end
end
private
def will_save_change_to_positioning_scope?
position_scope.any? do |scope_component|
will_save_change_to_attribute?(scope_component)
end
end
def positioning_scope
self.class.base_class.where(position_scope.to_h { |scope_component|
[scope_component, send(scope_component)]
})
end
def automatically_assign_position
if subsequent_id_changed?
self.position = subsequent.position
elsif will_save_change_to_positioning_scope? && !position_changed? || !position
self.position = (positioning_scope.maximum(:position) || 0) + 1
end
end
def shuffle_positions
if new_record?
positioning_scope.where(position: position..).order(position: :desc)
.update_all 'position = (position + 1)'
else
position_was = self.class.base_class.where(id: id).pick(:position)
self.class.base_class.where(id: id).update_all position: 0
if will_save_change_to_positioning_scope?
position_scope_was = position_scope.zip(
self.class.base_class.where(id: id).pick(*position_scope)
).to_h
self.class.base_class.where(position_scope_was)
.where(position: position_was..).order(position: :asc)
.update_all 'position = (position - 1)'
positioning_scope.where(position: position..).order(position: :desc)
.update_all 'position = (position + 1)'
else
if position_was > position
positioning_scope.where(position: position..(position_was - 1)).order(position: :desc)
.update_all 'position = (position + 1)'
else
positioning_scope.where(position: (position_was + 1)..position).order(position: :asc)
.update_all 'position = (position - 1)'
end
end
end
end
def destroyed_via_position_scope?
destroyed_by_association && position_scope.any? do |scope_component|
destroyed_by_association.foreign_key == scope_component
end
end
def fill_gap
positioning_scope.where(position: (position + 1)..).order(position: :asc)
.update_all 'position = (position - 1)'
end
end
class Thing < ApplicationRecord
include Positioning
belongs_to :parent_thing
positioned_on :parent_thing, :some_local_column
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment