From 2aede88152159b77f4dd07190b7c255a10894d1b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 14:45:06 +0200 Subject: [PATCH] feat(orm): extend list_position with insert_at --- lib/prism/orm/list_position.rb | 136 +++++++++++++++++++++++++++++++++ lib/prism/orm/locking.rb | 51 +++++++++++++ lib/prism/orm/migration.rb | 18 ++++- 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 lib/prism/orm/locking.rb diff --git a/lib/prism/orm/list_position.rb b/lib/prism/orm/list_position.rb index dba3d51..8f0c72b 100644 --- a/lib/prism/orm/list_position.rb +++ b/lib/prism/orm/list_position.rb @@ -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 diff --git a/lib/prism/orm/locking.rb b/lib/prism/orm/locking.rb new file mode 100644 index 0000000..a229893 --- /dev/null +++ b/lib/prism/orm/locking.rb @@ -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 diff --git a/lib/prism/orm/migration.rb b/lib/prism/orm/migration.rb index a85669a..86d7f40 100644 --- a/lib/prism/orm/migration.rb +++ b/lib/prism/orm/migration.rb @@ -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