Last active
June 25, 2023 21:42
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ParentThing | |
has_many :things, -> { order(:position) }, dependent: :destroy | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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