feat(query): add indexable_ranges and indexable_in_lists
This commit is contained in:
1 parent
1eef453b4e
commit
4e8b73332d
1 file changed
+205
@@ -156,3 +156,208 @@ module Prism
|
|||||||
expected.is_a?(Range) || expected.is_a?(Regexp) }
|
expected.is_a?(Range) || expected.is_a?(Regexp) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A Hash expected-value is only a pure range when every operator key
|
||||||
|
# is one of gt/gte/lt/lte - a mix with :in, :like, :ne, :is_null etc.
|
||||||
|
# can't be represented as a single contiguous bound, so that column
|
||||||
|
# is skipped here (safe: the caller just falls back to scanning).
|
||||||
|
RANGE_OPERATORS = %i[gt gte lt lte].freeze
|
||||||
|
|
||||||
|
def indexable_ranges
|
||||||
|
@conditions.each_with_object({}) do |(column, expected), acc|
|
||||||
|
bounds = range_bounds_for(expected)
|
||||||
|
acc[column] = bounds if bounds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# An Array expected-value is exactly an IN-list - the one Array
|
||||||
|
# shape #indexable_equalities above has to reject (it can't collapse
|
||||||
|
# to one scalar key), but it is precisely what
|
||||||
|
# Store#lookup_by_index_in wants.
|
||||||
|
def indexable_in_lists
|
||||||
|
@conditions.each_with_object({}) do |(column, expected), acc|
|
||||||
|
acc[column] = expected if expected.is_a?(Array)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def range_bounds_for(expected)
|
||||||
|
case expected
|
||||||
|
when Hash
|
||||||
|
bounds = expected.each_with_object({}) { |(op, value), h| h[op.to_sym] = value }
|
||||||
|
bounds.empty? || !bounds.keys.all? { |op| RANGE_OPERATORS.include?(op) } ? nil : bounds
|
||||||
|
when Range
|
||||||
|
bounds = {}
|
||||||
|
bounds[:gte] = expected.begin unless expected.begin.nil?
|
||||||
|
bounds[expected.exclude_end? ? :lt : :lte] = expected.end unless expected.end.nil?
|
||||||
|
bounds.empty? ? nil : bounds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cast_enum_values(conditions, model)
|
||||||
|
return conditions unless model.respond_to?(:enum_definitions)
|
||||||
|
enums = model.enum_definitions
|
||||||
|
return conditions if enums.empty?
|
||||||
|
|
||||||
|
conditions.each_with_object({}) do |(column, value), acc|
|
||||||
|
acc[column] = enums.key?(column) ? cast_value(model, column, value) : value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cast_value(model, column, value)
|
||||||
|
case value
|
||||||
|
when Array then value.map { |item| model.enum_cast(column, item) }
|
||||||
|
when Hash then cast_operator_hash(model, column, value)
|
||||||
|
when Range, Regexp then value
|
||||||
|
else model.enum_cast(column, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cast_operator_hash(model, column, operators)
|
||||||
|
operators.each_with_object({}) do |(operator, value), acc|
|
||||||
|
acc[operator] = if operator.to_sym == :is_null
|
||||||
|
value
|
||||||
|
elsif value.is_a?(Array)
|
||||||
|
value.map { |item| model.enum_cast(column, item) }
|
||||||
|
else
|
||||||
|
model.enum_cast(column, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same idea as cast_enum_values, but for `encrypts ..., deterministic:
|
||||||
|
# true` columns (see Orm::Encryption) - `where(ssn: plaintext)` is
|
||||||
|
# translated to the matching ciphertext before it ever reaches
|
||||||
|
# Predicates.evaluate, which only ever sees raw stored values.
|
||||||
|
def cast_encrypted_values(conditions, model)
|
||||||
|
return conditions unless model.respond_to?(:encrypted_attributes)
|
||||||
|
encrypted = model.encrypted_attributes
|
||||||
|
return conditions if encrypted.empty?
|
||||||
|
|
||||||
|
conditions.each_with_object({}) do |(column, value), acc|
|
||||||
|
acc[column] = encrypted.key?(column) ? cast_encrypted_value(model, column, value) : value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# model.encrypt_for_query returns a bare ciphertext normally, but an
|
||||||
|
# Array of candidate ciphertexts (current key plus every previous
|
||||||
|
# key still configured for rotation) once there is more than one
|
||||||
|
# key that might have produced what is sitting on disk - flat_map +
|
||||||
|
# Array(...) collapses both shapes into one flat IN-list without an
|
||||||
|
# explicit branch on which one came back.
|
||||||
|
def cast_encrypted_value(model, column, value)
|
||||||
|
case value
|
||||||
|
when Array then value.flat_map { |item| Array(model.encrypt_for_query(column, item)) }
|
||||||
|
when Hash then cast_encrypted_operator_hash(model, column, value)
|
||||||
|
when Range, Regexp then value
|
||||||
|
else model.encrypt_for_query(column, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cast_encrypted_operator_hash(model, column, operators)
|
||||||
|
operators.each_with_object({}) do |(operator, value), acc|
|
||||||
|
acc[operator] = if operator.to_sym == :is_null
|
||||||
|
value
|
||||||
|
elsif value.is_a?(Array)
|
||||||
|
value.flat_map { |item| Array(model.encrypt_for_query(column, item)) }
|
||||||
|
else
|
||||||
|
model.encrypt_for_query(column, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class AndNode < ConditionNode
|
||||||
|
def initialize(left, right)
|
||||||
|
@left = left
|
||||||
|
@right = right
|
||||||
|
end
|
||||||
|
|
||||||
|
def matches?(row)
|
||||||
|
@left.matches?(row) && @right.matches?(row)
|
||||||
|
end
|
||||||
|
|
||||||
|
def indexable_equalities
|
||||||
|
@left.indexable_equalities.merge(@right.indexable_equalities)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merges per-column bounds from both sides rather than overwriting -
|
||||||
|
# `where(age: { gte: 18 }).where(age: { lte: 65 })` must keep both
|
||||||
|
# the lower and the upper bound, not just whichever side merge picks
|
||||||
|
# last. When both sides constrain the same operator on the same
|
||||||
|
# column (redundant/contradictory input), which value wins doesn't
|
||||||
|
# matter for correctness: either is still a safe over-approximation
|
||||||
|
# of the true intersection, and the final #matches? re-checks both.
|
||||||
|
def indexable_ranges
|
||||||
|
left = @left.indexable_ranges
|
||||||
|
right = @right.indexable_ranges
|
||||||
|
(left.keys | right.keys).each_with_object({}) do |column, acc|
|
||||||
|
acc[column] = (left[column] || {}).merge(right[column] || {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unlike #indexable_ranges above, both sides having the SAME column
|
||||||
|
# here is safely narrowed with a real intersection (not just "pick
|
||||||
|
# one and let #matches? sort it out" as ranges do) - `where(id:
|
||||||
|
# [1,2,3]).where(id: [2,3,4])` needs a row's id to be in both lists,
|
||||||
|
# so `& ` is both a correct AND tighter candidate set than either
|
||||||
|
# side alone, cheap for the small id/foreign-key lists these come
|
||||||
|
# from in practice (eager-loading batches, not user-supplied IN
|
||||||
|
# clauses of unbounded size).
|
||||||
|
def indexable_in_lists
|
||||||
|
left = @left.indexable_in_lists
|
||||||
|
right = @right.indexable_in_lists
|
||||||
|
(left.keys | right.keys).each_with_object({}) do |column, acc|
|
||||||
|
acc[column] = if left.key?(column) && right.key?(column)
|
||||||
|
left[column] & right[column]
|
||||||
|
else
|
||||||
|
left[column] || right[column]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class OrNode < ConditionNode
|
||||||
|
def initialize(left, right)
|
||||||
|
@left = left
|
||||||
|
@right = right
|
||||||
|
end
|
||||||
|
|
||||||
|
def matches?(row)
|
||||||
|
@left.matches?(row) || @right.matches?(row)
|
||||||
|
end
|
||||||
|
|
||||||
|
# An Or is only index-answerable when BOTH branches are - covering
|
||||||
|
# just one side and then still scanning the rest of the table for
|
||||||
|
# the other buys nothing over scanning for the whole condition, so
|
||||||
|
# this deliberately returns nil (scan) rather than "index one side,
|
||||||
|
# scan for the other" false economy. When both sides do hit an
|
||||||
|
# index (e.g. `Comment.approved.or(where(user_id: id))` once both
|
||||||
|
# `status` and `user_id` carry an index), the union of both id sets
|
||||||
|
# is exactly the Or's result set - matches? still re-checks every
|
||||||
|
# candidate afterward in Relation#rows, so a row present in both
|
||||||
|
# branches being deduplicated here is just an optimization, not a
|
||||||
|
# correctness requirement.
|
||||||
|
def indexed_candidates(store, table)
|
||||||
|
left_hit = @left.indexed_candidates(store, table)
|
||||||
|
return nil if left_hit.nil?
|
||||||
|
|
||||||
|
right_hit = @right.indexed_candidates(store, table)
|
||||||
|
return nil if right_hit.nil?
|
||||||
|
|
||||||
|
(left_hit + right_hit).uniq { |row| row['id'] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class NotNode < ConditionNode
|
||||||
|
def initialize(node)
|
||||||
|
@node = node
|
||||||
|
end
|
||||||
|
|
||||||
|
def matches?(row)
|
||||||
|
!@node.matches?(row)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user