feat(orm): wire up Prism and Orm

This commit is contained in:
dualfroz committed 2026-09-08 17:27:17 +02:00
1 parent 4ce9f3e66a
commit 18c885feff
2 files changed
+179

No files matched your search

+66
View File
@@ -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
+113
View File
@@ -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
"#<Predicate #{@operator} #{@argument.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