feat(orm): wire up Prism and Orm
This commit is contained in:
1 parent
4ce9f3e66a
commit
18c885feff
2 files changed
+179
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user