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
|
self
|
||||||
end
|
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
|
module Orm
|
||||||
class Migration
|
class Migration
|
||||||
class TableDefinition
|
class TableDefinition
|
||||||
attr_reader :columns
|
attr_reader :columns, :indexes
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@columns = ['id']
|
@columns = ['id']
|
||||||
|
@indexes = []
|
||||||
end
|
end
|
||||||
|
|
||||||
def column(name, _type = :string)
|
def column(name, _type = :string)
|
||||||
@@ -20,6 +21,10 @@ module Prism
|
|||||||
column(:created_at)
|
column(:created_at)
|
||||||
column(:updated_at)
|
column(:updated_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def index(columns, unique: false, name: nil)
|
||||||
|
@indexes << { columns: Array(columns), unique: unique, name: name }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(store)
|
def initialize(store)
|
||||||
@@ -42,6 +47,9 @@ module Prism
|
|||||||
definition = TableDefinition.new
|
definition = TableDefinition.new
|
||||||
yield(definition) if block_given?
|
yield(definition) if block_given?
|
||||||
store.create_table(name, definition.columns)
|
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
|
end
|
||||||
|
|
||||||
def drop_table(name)
|
def drop_table(name)
|
||||||
@@ -55,6 +63,14 @@ module Prism
|
|||||||
def remove_column(table, column)
|
def remove_column(table, column)
|
||||||
store.remove_column(table, column)
|
store.remove_column(table, column)
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
Reference in new issue
Block a user