feat(orm): extend list_position with insert_at
This commit is contained in:
1 parent
7538a886cf
commit
2aede88152
3 files changed
+204
-1
No files matched your search
@@ -148,3 +148,139 @@ module Prism
|
||||
self
|
||||
end
|
||||
|
||||
# General-purpose reposition for an already-persisted record: shifts
|
||||
# every sibling strictly between the old and new position by one
|
||||
# (toward the gap this move opens up) inside a single transaction,
|
||||
# then moves this record itself - the same three-step shift the gem
|
||||
# does with update_all, just expressed as one increment_counter call
|
||||
# per affected row since this Store has no bulk UPDATE.
|
||||
#
|
||||
# Also wrapped in with_lock - a caller reaching this directly (not
|
||||
# through move_to_top/move_to_bottom) hits the same TOCTOU window as
|
||||
# move_higher/move_lower did: old_position used to be read from
|
||||
# whatever self happened to hold in memory, which could already be
|
||||
# stale by the time the write transaction opened. Nesting inside an
|
||||
# already-open with_lock (as move_to_top/move_to_bottom now do) is
|
||||
# safe - Store#within_transaction/Pessimistic#lock! are both
|
||||
# reentrant on an already-open transaction, so this does not try to
|
||||
# take the file lock twice.
|
||||
def insert_at(new_position)
|
||||
raise RecordNotFound, "cannot reposition a #{self.class} that is not persisted" unless persisted?
|
||||
column = self.class.list_position_column
|
||||
with_lock do
|
||||
old_position = list_position
|
||||
next if old_position == new_position
|
||||
|
||||
if old_position.nil?
|
||||
shift_siblings_at_or_after(new_position, by: 1)
|
||||
elsif new_position < old_position
|
||||
list_scope_relation.where(column => { gte: new_position, lt: old_position }).each do |sibling|
|
||||
self.class.store.increment_counter(self.class.table_name, sibling.id, column, 1) unless sibling.id == id
|
||||
end
|
||||
else
|
||||
list_scope_relation.where(column => { gt: old_position, lte: new_position }).each do |sibling|
|
||||
self.class.store.increment_counter(self.class.table_name, sibling.id, column, -1) unless sibling.id == id
|
||||
end
|
||||
end
|
||||
self.class.store.increment_counter(self.class.table_name, id, column, new_position - old_position.to_i)
|
||||
end
|
||||
@attributes[column] = new_position
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def swap_list_position_with(other)
|
||||
column = self.class.list_position_column
|
||||
self_position = list_position
|
||||
other_position = other[column]
|
||||
self.class.transaction do
|
||||
self.class.store.increment_counter(self.class.table_name, other.id, column, self_position - other_position)
|
||||
self.class.store.increment_counter(self.class.table_name, id, column, other_position - self_position)
|
||||
end
|
||||
other.instance_variable_get(:@attributes)[column] = self_position
|
||||
@attributes[column] = other_position
|
||||
end
|
||||
|
||||
# Shared by insert_at's "no prior position" branch and by the
|
||||
# before_create hook when a caller assigns a position explicitly
|
||||
# (e.g. Model.create!(position: 2, ...)) instead of relying on the
|
||||
# bottom-of-list default - existing rows at or past the target slide
|
||||
# down to make room for the record about to occupy it.
|
||||
def shift_siblings_at_or_after(position, by:)
|
||||
column = self.class.list_position_column
|
||||
list_scope_relation.where(column => { gte: position }).each do |sibling|
|
||||
self.class.store.increment_counter(self.class.table_name, sibling.id, column, by) unless persisted? && sibling.id == id
|
||||
end
|
||||
end
|
||||
|
||||
def prism_list_position_append_on_create
|
||||
column = self.class.list_position_column
|
||||
if list_position
|
||||
shift_siblings_at_or_after(list_position, by: 1)
|
||||
else
|
||||
self[column] = (list_scope_relation.maximum(column) || 0) + 1
|
||||
end
|
||||
end
|
||||
|
||||
# Fires only when the scope column itself is dirty (e.g. Post#featured
|
||||
# flipped by an edit form - see examples/blog) - an ordinary update
|
||||
# that never touches the scope column leaves list_position completely
|
||||
# alone. attribute_was(scope_column) reads the PRE-change value while
|
||||
# this hook still runs inside the same before_update Dirty sees it in
|
||||
# (see Dirty#attribute_was), which is why the "close the gap" query
|
||||
# below is built by hand against that old value instead of going
|
||||
# through list_scope_relation - that helper always reads self's
|
||||
# CURRENT (already-reassigned) scope value, which by now is the new
|
||||
# one this record is about to join, not the one it is leaving.
|
||||
def prism_list_position_switch_scope_on_update
|
||||
scope_column = self.class.list_position_scope_column
|
||||
return unless scope_column && attribute_changed?(scope_column)
|
||||
column = self.class.list_position_column
|
||||
old_scope_value = attribute_was(scope_column)
|
||||
old_position = self[column]
|
||||
if old_position
|
||||
self.class.all.where(scope_column => old_scope_value, column => { gt: old_position }).each do |sibling|
|
||||
self.class.store.increment_counter(self.class.table_name, sibling.id, column, -1)
|
||||
end
|
||||
end
|
||||
# The store row this record occupies still holds the OLD scope
|
||||
# value (this update has not been written yet), so a plain query
|
||||
# for the new scope's current bottom naturally excludes self
|
||||
# without needing to filter it out by id, same as
|
||||
# prism_list_position_reappend_on_restore relies on for the same
|
||||
# reason.
|
||||
new_bottom = self.class.all.where(scope_column => self[scope_column]).maximum(column)
|
||||
self[column] = (new_bottom || 0) + 1
|
||||
end
|
||||
|
||||
# Runs for both a real delete and a paranoid soft-delete (Model#destroy
|
||||
# runs after_destroy either way - see Paranoia) since in both cases
|
||||
# the row stops showing up in list_scope_relation (paranoid_scope
|
||||
# hides it, or it is just gone), so either way the gap it leaves
|
||||
# needs closing the same way.
|
||||
def prism_list_position_close_gap
|
||||
pos = list_position
|
||||
return unless pos
|
||||
column = self.class.list_position_column
|
||||
list_scope_relation.where(column => { gt: pos }).each do |sibling|
|
||||
self.class.store.increment_counter(self.class.table_name, sibling.id, column, -1)
|
||||
end
|
||||
end
|
||||
|
||||
# A restored record's OLD position (untouched by soft-delete/restore,
|
||||
# which only ever stamp/clear the paranoid column) may now collide
|
||||
# with whatever slid down into that slot while it was trashed (see
|
||||
# prism_list_position_close_gap above) - reappending it to the
|
||||
# bottom, exactly like a fresh create, is the same call Rails
|
||||
# paranoia + acts_as_list users hit this same way: a restored record
|
||||
# does not reclaim its old spot, it rejoins at the end.
|
||||
def prism_list_position_reappend_on_restore
|
||||
column = self.class.list_position_column
|
||||
new_position = (list_scope_relation.maximum(column) || 0) + 1
|
||||
self.class.store.increment_counter(self.class.table_name, id, column, new_position - list_position.to_i)
|
||||
@attributes[column] = new_position
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
module Prism
|
||||
module Orm
|
||||
# ActiveRecord::Locking::Optimistic-lite. Raised when a save/destroy was
|
||||
# attempted against a lock_version the underlying row no longer has -
|
||||
# someone else already saved (or destroyed) the same record in between
|
||||
# this instance being loaded and this instance being saved.
|
||||
class StaleObjectError < StandardError
|
||||
attr_reader :record, :attempted_action
|
||||
|
||||
def initialize(record, attempted_action)
|
||||
@record = record
|
||||
@attempted_action = attempted_action
|
||||
super("Attempted to #{attempted_action} a stale #{record.class} " \
|
||||
"(id=#{record.id.inspect}, lock_version=#{record.lock_version.inspect}); " \
|
||||
'it was already modified or destroyed by another process')
|
||||
end
|
||||
end
|
||||
|
||||
# Optimistic locking: a `lock_version` integer column (auto-detected the
|
||||
# same way Model#touch_timestamps auto-detects created_at/updated_at -
|
||||
# present in the schema means enabled, no opt-in flag needed) turns a
|
||||
# blind last-write-wins update/destroy into one that raises instead of
|
||||
# silently clobbering a change made by another process in between this
|
||||
# record being loaded and being saved. The version check-and-increment
|
||||
# happens inside the same PStore transaction as the row write
|
||||
# (Store#update/#delete's lock_column/lock_version kwargs), so two
|
||||
# processes racing on the same row can never both succeed against the
|
||||
# same lock_version - the loser gets Store::StaleRowError, which Model
|
||||
# re-raises as the richer, record-carrying StaleObjectError above.
|
||||
module Locking
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def locking_column(name = nil)
|
||||
@locking_column = name.to_s if name
|
||||
@locking_column || 'lock_version'
|
||||
end
|
||||
|
||||
def locking_enabled?
|
||||
columns.include?(locking_column)
|
||||
end
|
||||
end
|
||||
|
||||
def lock_version
|
||||
self[self.class.locking_column]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,10 +2,11 @@ module Prism
|
||||
module Orm
|
||||
class Migration
|
||||
class TableDefinition
|
||||
attr_reader :columns
|
||||
attr_reader :columns, :indexes
|
||||
|
||||
def initialize
|
||||
@columns = ['id']
|
||||
@indexes = []
|
||||
end
|
||||
|
||||
def column(name, _type = :string)
|
||||
@@ -20,6 +21,10 @@ module Prism
|
||||
column(:created_at)
|
||||
column(:updated_at)
|
||||
end
|
||||
|
||||
def index(columns, unique: false, name: nil)
|
||||
@indexes << { columns: Array(columns), unique: unique, name: name }
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(store)
|
||||
@@ -42,6 +47,9 @@ module Prism
|
||||
definition = TableDefinition.new
|
||||
yield(definition) if block_given?
|
||||
store.create_table(name, definition.columns)
|
||||
definition.indexes.each do |index|
|
||||
store.add_index(name, index[:columns], unique: index[:unique], name: index[:name])
|
||||
end
|
||||
end
|
||||
|
||||
def drop_table(name)
|
||||
@@ -55,6 +63,14 @@ module Prism
|
||||
def remove_column(table, column)
|
||||
store.remove_column(table, column)
|
||||
end
|
||||
|
||||
def add_index(table, columns, unique: false, name: nil)
|
||||
store.add_index(table, columns, unique: unique, name: name)
|
||||
end
|
||||
|
||||
def remove_index(table, columns_or_name)
|
||||
store.remove_index(table, columns_or_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user