feat(query): add Prism and Orm
This commit is contained in:
1 parent
64c209f156
commit
1eef453b4e
1 file changed
+158
@@ -0,0 +1,158 @@
|
||||
module Prism
|
||||
module Orm
|
||||
module Query
|
||||
# A condition tree evaluated against a raw stored row (a String-keyed
|
||||
# Hash), letting Relation compose and/or/not without flattening
|
||||
# everything into a single Hash the way plain #where conditions do.
|
||||
class ConditionNode
|
||||
def and(other)
|
||||
return other if is_a?(MatchAll)
|
||||
return self if other.is_a?(MatchAll)
|
||||
AndNode.new(self, other)
|
||||
end
|
||||
|
||||
def or(other)
|
||||
OrNode.new(self, other)
|
||||
end
|
||||
|
||||
def negate
|
||||
NotNode.new(self)
|
||||
end
|
||||
|
||||
def matches?(_row)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
# Column => scalar-value equalities that are guaranteed to hold for
|
||||
# every row this condition matches - the safe over-approximation
|
||||
# Relation#rows uses to pick a covering index instead of scanning
|
||||
# the whole table (see Store#lookup_by_index). Defaults to none;
|
||||
# only HashNode (a plain equality) and AndNode (the intersection of
|
||||
# both sides) can contribute any - Or/Not can't guarantee a shared
|
||||
# value across branches, so they contribute nothing, which just
|
||||
# means the caller falls back to a full scan, never a wrong result.
|
||||
def indexable_equalities
|
||||
{}
|
||||
end
|
||||
|
||||
# Column => { gt:/gte:/lt:/lte: value } bounds that are guaranteed
|
||||
# to hold for every row this condition matches - the same safe
|
||||
# over-approximation idea as #indexable_equalities, but for a
|
||||
# contiguous range instead of a single value, letting Relation#rows
|
||||
# use Store#lookup_by_index_range (a binary search over a sorted
|
||||
# index) instead of a full scan. Defaults to none.
|
||||
def indexable_ranges
|
||||
{}
|
||||
end
|
||||
|
||||
# Column => Array-of-values that are guaranteed to cover every row
|
||||
# this condition matches - the safe over-approximation Relation#rows
|
||||
# uses to pick Store#lookup_by_index_in (a direct id-hash lookup, or
|
||||
# an equality-index probe per value) instead of scanning the whole
|
||||
# table for an IN-list condition. Defaults to none.
|
||||
def indexable_in_lists
|
||||
{}
|
||||
end
|
||||
|
||||
# Column => { equalities: {...}, bounds: {...} } for every column
|
||||
# that carries BOTH a range bound AND at least one OTHER column
|
||||
# pinned to an exact value in this same condition tree - the pair a
|
||||
# composite (scope, range-column) index answers via
|
||||
# Store#lookup_by_index_equality_range, the composite counterpart
|
||||
# of #indexable_ranges. Built once here from #indexable_equalities/
|
||||
# #indexable_ranges rather than overridden per subclass: both of
|
||||
# those already correctly aggregate across arbitrarily nested
|
||||
# AndNodes (that is their entire job), so this needs no HashNode-
|
||||
# or AndNode-specific logic of its own - a column can never appear
|
||||
# in both hashes at once (HashNode's #indexable_equalities
|
||||
# explicitly rejects Hash/Array/Range/Regexp values, exactly the
|
||||
# shapes #indexable_ranges requires), so `reject` below never
|
||||
# empties out the range column's own equality by accident.
|
||||
def indexable_equality_ranges
|
||||
equalities = indexable_equalities
|
||||
return {} if equalities.empty?
|
||||
|
||||
indexable_ranges.each_with_object({}) do |(column, bounds), acc|
|
||||
prefix = equalities.reject { |c, _| c == column }
|
||||
acc[column] = { equalities: prefix, bounds: bounds } unless prefix.empty?
|
||||
end
|
||||
end
|
||||
|
||||
# Array of matching row dups if this node can be answered entirely
|
||||
# from secondary indexes, or nil if the caller must fall back to a
|
||||
# full table scan - the same "nil means scan" contract as
|
||||
# Store#lookup_by_index. The default implementation covers HashNode
|
||||
# and AndNode: both only ever produce indexable_equalities/ranges/
|
||||
# in_lists/equality_ranges hashes, and this tries them against
|
||||
# `store` in that order (most selective first): an exact composite
|
||||
# match, then an IN-list, then an equality-prefix-plus-range
|
||||
# composite, then a plain single-column range. OrNode overrides
|
||||
# this (see below) since a Hash of column=>value can't represent
|
||||
# "candidates from either branch" - Not/MatchAll/MatchNone never
|
||||
# override it, so they fall through to nil via their empty
|
||||
# indexable_* hashes, exactly as before this method existed.
|
||||
def indexed_candidates(store, table)
|
||||
equalities = indexable_equalities
|
||||
unless equalities.empty?
|
||||
hit = store.lookup_by_index(table, equalities)
|
||||
return hit unless hit.nil?
|
||||
end
|
||||
|
||||
indexable_in_lists.each do |column, values|
|
||||
hit = store.lookup_by_index_in(table, column, values)
|
||||
return hit unless hit.nil?
|
||||
end
|
||||
|
||||
indexable_equality_ranges.each do |column, spec|
|
||||
hit = store.lookup_by_index_equality_range(table, spec[:equalities], column, spec[:bounds])
|
||||
return hit unless hit.nil?
|
||||
end
|
||||
|
||||
indexable_ranges.each do |column, operators|
|
||||
hit = store.lookup_by_index_range(table, column, operators)
|
||||
return hit unless hit.nil?
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
class MatchAll < ConditionNode
|
||||
def matches?(_row)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
class MatchNone < ConditionNode
|
||||
def matches?(_row)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
class HashNode < ConditionNode
|
||||
# `model:` lets a Symbol/String enum label (`where(status: :draft)`,
|
||||
# `where(status: [:draft, :published])`, `where(status: { not_in:
|
||||
# [:archived] })`) be translated to the raw stored value the same
|
||||
# way Enum#enum_cast already does for a plain assignment - without
|
||||
# it, `Post.where(status: :draft)` would silently compare the raw
|
||||
# Integer column against the Symbol and never match. A raw value
|
||||
# passed directly (`where(status: 0)`) still round-trips unchanged.
|
||||
def initialize(conditions, model: nil)
|
||||
@conditions = conditions.transform_keys(&:to_s)
|
||||
@conditions = cast_enum_values(@conditions, model) if model
|
||||
@conditions = cast_encrypted_values(@conditions, model) if model
|
||||
end
|
||||
|
||||
def matches?(row)
|
||||
@conditions.all? { |key, expected| Predicates.evaluate(row[key], expected) }
|
||||
end
|
||||
|
||||
# Only conditions compared with a bare scalar (or nil) are safe to
|
||||
# feed into an index lookup - a Hash/Array/Range/Regexp expected
|
||||
# value means an operator, IN-list, range, or pattern match, none of
|
||||
# which narrow down to one exact stored value.
|
||||
def indexable_equalities
|
||||
@conditions.reject { |_key, expected| expected.is_a?(Hash) || expected.is_a?(Array) ||
|
||||
expected.is_a?(Range) || expected.is_a?(Regexp) }
|
||||
end
|
||||
|
||||
Reference in new issue
Block a user