From 18c885feff4febceb482cca9270940b91213335d Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 17:27:17 +0200 Subject: [PATCH] feat(orm): wire up Prism and Orm --- lib/prism/orm/pessimistic.rb | 66 ++++++++++++++++++++ lib/prism/orm/predicate.rb | 113 +++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 lib/prism/orm/pessimistic.rb create mode 100644 lib/prism/orm/predicate.rb diff --git a/lib/prism/orm/pessimistic.rb b/lib/prism/orm/pessimistic.rb new file mode 100644 index 0000000..b925158 --- /dev/null +++ b/lib/prism/orm/pessimistic.rb @@ -0,0 +1,66 @@ +module Prism + module Orm + # ActiveRecord::Locking::Pessimistic-lite. Where Locking (optimistic, + # lock_version) detects a conflict AFTER the fact and raises + # StaleObjectError, this PREVENTS the conflict from happening at all - + # it holds Store's real cross-process file lock (PStore#transaction + # flocks the underlying file with File::LOCK_EX, see + # Store#within_transaction) for as long as the caller's block runs, so a + # concurrent process racing on the same row cannot even start its own + # transaction until this one commits or rolls back. No extra column, + # no schema opt-in - it rides the file lock every transaction already + # takes. + # + # `lock!` alone is exactly as limited as in ActiveRecord: it opens and + # closes its OWN one-statement transaction, so the lock is released the + # instant it returns - by the time the caller's next line runs, another + # process could already have grabbed the file lock in between. The + # useful entry point is `with_lock`, which keeps a single transaction + # (and therefore the file lock) open across the reload AND the block. + module Pessimistic + class UnpersistedRecordError < StandardError + def initialize + super('cannot lock a record that has not been saved yet') + end + end + + # Rails raises the same shape of error: reload would silently throw + # away in-memory changes the caller has not saved, which is very + # unlikely to be what anyone locking a record for a critical section + # actually wants. + class DirtyRecordError < StandardError + def initialize + super('cannot lock a record with unsaved changes - save or ' \ + 'discard them first, reload would silently drop them') + end + end + + # Reloads this record inside a (possibly brand new) transaction. When + # there is no transaction open yet, `transaction` here opens a real + # one around just this reload, which - same limitation as Rails - is + # gone again as soon as this method returns. Called from inside + # `with_lock` instead, it joins that already-open transaction, so the + # exclusive file lock acquired to open it is still held afterwards. + def lock! + raise UnpersistedRecordError if id.nil? + raise DirtyRecordError if changed? + transaction { reload } + self + end + + # Opens a transaction, locks this record inside it, then runs the + # block still inside that same transaction - the file lock taken to + # open it stays held for the block's entire duration, so any other + # process attempting its own transaction on this Store blocks until + # this one commits (or rolls back). That is what actually prevents + # the lost-update race `lock!` alone cannot: two processes both + # reading the pre-update value and both writing based on it. + def with_lock + transaction do + lock! + yield self + end + end + end + end +end diff --git a/lib/prism/orm/predicate.rb b/lib/prism/orm/predicate.rb new file mode 100644 index 0000000..174cf7a --- /dev/null +++ b/lib/prism/orm/predicate.rb @@ -0,0 +1,113 @@ +require 'prism' + +module Prism + module Orm + class Predicate + OPERATORS = %i[ + eq not_eq gt gteq lt lteq in not_in between + matches does_not_match starts_with ends_with is_null is_not_null + ].freeze + + ALIASES = { ne: :not_eq, gte: :gteq, lte: :lteq, like: :matches, unlike: :does_not_match }.freeze + + attr_reader :operator, :argument + + def initialize(operator, argument = nil) + @operator = operator.to_sym + @argument = argument + end + + class << self + def parse(value) + case value + when Predicate then [value] + when nil then [new(:is_null)] + when Range then [new(:between, value)] + when Array then [new(:in, value)] + when Hash then value.map { |operator, argument| from_pair(operator, argument) } + else [new(:eq, value)] + end + end + + def from_pair(operator, argument) + canonical = ALIASES.fetch(operator.to_sym, operator.to_sym) + raise ArgumentError, "unknown predicate operator #{operator}" unless OPERATORS.include?(canonical) + if %i[is_null is_not_null].include?(canonical) + argument == false ? new(canonical == :is_null ? :is_not_null : :is_null) : new(canonical) + else + new(canonical, argument) + end + end + + %i[eq not_eq gt gteq lt lteq between matches does_not_match starts_with ends_with].each do |operator| + define_method(operator) { |argument| new(operator, argument) } + end + + define_method(:in) { |argument| new(:in, argument) } + define_method(:not_in) { |argument| new(:not_in, argument) } + define_method(:is_null) { new(:is_null) } + define_method(:is_not_null) { new(:is_not_null) } + end + + def matches_row?(row, column) + matches_value?(row[column.to_s]) + end + + def matches_value?(actual) + case @operator + when :eq then actual == @argument + when :not_eq then actual != @argument + when :gt then comparison(actual) == 1 + when :gteq then [0, 1].include?(comparison(actual)) + when :lt then comparison(actual) == -1 + when :lteq then [0, -1].include?(comparison(actual)) + when :in then contained?(actual) + when :not_in then !contained?(actual) + when :between then !actual.nil? && @argument.cover?(actual) + when :matches then !actual.nil? && like_regex(@argument).match?(actual.to_s) + when :does_not_match then actual.nil? || !like_regex(@argument).match?(actual.to_s) + when :starts_with then !actual.nil? && actual.to_s.start_with?(@argument.to_s) + when :ends_with then !actual.nil? && actual.to_s.end_with?(@argument.to_s) + when :is_null then actual.nil? + when :is_not_null then !actual.nil? + end + end + + def ==(other) + other.is_a?(Predicate) && other.operator == @operator && other.argument == @argument + end + + def inspect + "#" + end + + private + + def comparison(actual) + return nil if actual.nil? + actual <=> @argument + end + + def contained?(actual) + if @argument.is_a?(Range) + @argument.cover?(actual) + else + @argument.include?(actual) + end + end + + def like_regex(pattern) + source = +'\A' + pattern.to_s.each_char do |character| + case character + when '%' then source << '.*' + when '_' then source << '.' + else source << Regexp.escape(character) + end + end + source << '\z' + Regexp.new(source) + end + end + end +end