feat(orm): extend store with add_index and remove_index
This commit is contained in:
1 parent
7a8dcd388c
commit
c422c8b367
1 file changed
+204
@@ -247,6 +247,210 @@ module Prism
|
||||
within_transaction { (@pstore[:schema_migrations] || []).delete(version.to_s) }
|
||||
end
|
||||
|
||||
# Builds a secondary index over `columns` (one column, or several for a
|
||||
# composite index) so `Relation#where` equality lookups on those exact
|
||||
# columns can fetch matching rows directly instead of scanning the
|
||||
# whole table (see Relation#rows/#indexed_candidates). Backfills from
|
||||
# every existing row, the same way a real database builds an index at
|
||||
# CREATE INDEX time - not lazily on first query.
|
||||
def add_index(table, columns, unique: false, name: nil)
|
||||
within_transaction do
|
||||
definition = table_definition(table)
|
||||
columns = Array(columns).map(&:to_s)
|
||||
index_name = (name || "index_#{table}_on_#{columns.join('_and_')}").to_s
|
||||
definition[:indexes] ||= {}
|
||||
raise IndexExists, "index #{index_name} already exists on #{table}" if definition[:indexes].key?(index_name)
|
||||
|
||||
definition[:indexes][index_name] =
|
||||
build_index_data(definition, columns, unique: unique, name: index_name)
|
||||
index_name
|
||||
end
|
||||
end
|
||||
|
||||
def remove_index(table, name_or_columns)
|
||||
within_transaction do
|
||||
definition = table_definition(table)
|
||||
definition[:indexes] ||= {}
|
||||
index_name = resolve_index_name(definition, name_or_columns)
|
||||
raise UnknownIndex, "no index #{name_or_columns.inspect} on #{table}" unless index_name
|
||||
definition[:indexes].delete(index_name)
|
||||
end
|
||||
end
|
||||
|
||||
def indexes(table)
|
||||
within_transaction(read_only: true) do
|
||||
(table_definition(table)[:indexes] || {}).values.map do |index|
|
||||
{ name: index[:name], columns: index[:columns].dup, unique: index[:unique] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns matching row dups for an exact-match lookup on the best
|
||||
# available index covering every key in `equalities` (all columns of
|
||||
# some index must be present), or nil if no such index exists - the
|
||||
# signal for the caller to fall back to a full table scan.
|
||||
def lookup_by_index(table, equalities)
|
||||
return nil if equalities.empty?
|
||||
|
||||
# `id` is always "indexed" for free - `definition[:rows]` is already
|
||||
# a Hash keyed by id, exactly the same reasoning #lookup_by_index_in
|
||||
# already applies to an IN-list of ids. Before this, only the
|
||||
# IN-list path got that fast lookup; a plain single-id equality
|
||||
# (`find_by(id: x)`, `where(id: x).first`, and therefore every
|
||||
# Authenticatable#current_user call on every authenticated request)
|
||||
# fell all the way through to a registered-index search that could
|
||||
# never find one (no framework code ever calls `add_index(table,
|
||||
# :id)`) and landed on a full table scan instead - silently, since
|
||||
# nothing about that path looked like an error. Any other equality
|
||||
# keys alongside `id` are still safe to ignore here: at most one row
|
||||
# can ever match on id anyway, and Relation#rows always re-applies
|
||||
# the full condition afterward.
|
||||
if equalities.key?('id')
|
||||
return instrument('SELECT INDEX', table, columns: ['id']) do
|
||||
rows_by_id(table, [equalities['id']])
|
||||
end
|
||||
end
|
||||
|
||||
available = within_transaction(read_only: true) do
|
||||
(table_definition(table)[:indexes] || {}).values
|
||||
end
|
||||
index = available.select { |candidate| candidate[:columns].all? { |c| equalities.key?(c) } }
|
||||
.max_by { |candidate| candidate[:columns].size }
|
||||
return nil unless index
|
||||
|
||||
instrument('SELECT INDEX', table, columns: index[:columns]) do
|
||||
within_transaction(read_only: true) do
|
||||
definition = table_definition(table)
|
||||
key = index[:columns].map { |column| equalities[column] }
|
||||
ids = (table_definition(table)[:indexes][index[:name]][:map][key] || [])
|
||||
ids.map { |id| definition[:rows][id]&.dup }.compact
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns matching row dups for a gt/gte/lt/lte range on the best
|
||||
# available range-capable index covering `column` (single-column
|
||||
# indexes only), or nil if none exists - the same "nil means scan"
|
||||
# contract as #lookup_by_index. `operators` is a Hash of Symbol
|
||||
# operator => bound, e.g. { gte: 18, lt: 65 }. Finds the boundary with
|
||||
# a binary search over the index's ascending sorted_values (O(log n)),
|
||||
# then only touches the ids for values inside those bounds - never a
|
||||
# scan of the full distinct-value list, let alone the whole table.
|
||||
def lookup_by_index_range(table, column, operators)
|
||||
column = column.to_s
|
||||
index = within_transaction(read_only: true) do
|
||||
(table_definition(table)[:indexes] || {}).values
|
||||
.find { |candidate| candidate[:range_capable] && candidate[:columns] == [column] }
|
||||
end
|
||||
return nil unless index
|
||||
|
||||
instrument('SELECT INDEX RANGE', table, columns: index[:columns]) do
|
||||
within_transaction(read_only: true) do
|
||||
definition = table_definition(table)
|
||||
sorted = definition[:indexes][index[:name]][:sorted_values]
|
||||
lo, hi = range_bounds(sorted, operators)
|
||||
ids = sorted[lo...hi].flat_map { |value| definition[:indexes][index[:name]][:map][[value]] || [] }
|
||||
ids.uniq.map { |id| definition[:rows][id]&.dup }.compact
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns matching row dups for an equality-prefix-plus-range lookup
|
||||
# (`where(scope: val, position: { lt: x })`) on the best available
|
||||
# composite index whose own columns are `[*some subset of
|
||||
# equalities.keys in the index's declared order, range_column]`, or
|
||||
# nil if none exists - same "nil means scan" contract as every other
|
||||
# lookup_by_index* method. Unlike #lookup_by_index_range (single
|
||||
# column only), this walks a composite index's *last* column as the
|
||||
# range dimension while every other column is pinned to an exact
|
||||
# value - the same thing a real database does with a composite
|
||||
# B-tree index `(scope, position)` answering `WHERE scope = ? AND
|
||||
# position < ?` by seeking straight into the `scope` partition
|
||||
# instead of scanning the whole table (or, without this method, every
|
||||
# row in every OTHER scope too - see #lookup_by_index_range's
|
||||
# single-column limitation).
|
||||
#
|
||||
# `equalities` is allowed to carry MORE columns than the index's own
|
||||
# prefix needs (e.g. a paranoid model's `deleted_at: nil` riding
|
||||
# alongside `featured: true` when only `featured` is actually
|
||||
# indexed) - only the index's own declared prefix columns are read
|
||||
# out of it to build the lookup key, exactly like #lookup_by_index's
|
||||
# "index columns must be a subset of equalities.keys" contract (not
|
||||
# the other way around). Any equality condition the index prefix
|
||||
# doesn't cover is still safe to drop here: Relation#rows re-checks
|
||||
# the full condition against every candidate row afterward. When
|
||||
# several composite indexes could answer the same call, the one
|
||||
# covering the most columns wins, same tie-break as #lookup_by_index.
|
||||
def lookup_by_index_equality_range(table, equalities, range_column, operators)
|
||||
equalities = equalities.transform_keys(&:to_s)
|
||||
range_column = range_column.to_s
|
||||
|
||||
index = within_transaction(read_only: true) do
|
||||
(table_definition(table)[:indexes] || {}).values
|
||||
.select do |candidate|
|
||||
candidate[:composite_range_capable] &&
|
||||
candidate[:columns].last == range_column &&
|
||||
candidate[:columns][0..-2].all? { |column| equalities.key?(column) }
|
||||
end
|
||||
.max_by { |candidate| candidate[:columns].size }
|
||||
end
|
||||
return nil unless index
|
||||
|
||||
instrument('SELECT INDEX RANGE', table, columns: index[:columns]) do
|
||||
within_transaction(read_only: true) do
|
||||
definition = table_definition(table)
|
||||
prefix_key = index[:columns][0..-2].map { |column| equalities[column] }
|
||||
sorted = definition[:indexes][index[:name]][:sorted_by_prefix][prefix_key] || []
|
||||
lo, hi = range_bounds(sorted, operators)
|
||||
ids = sorted[lo...hi].flat_map do |value|
|
||||
definition[:indexes][index[:name]][:map][prefix_key + [value]] || []
|
||||
end
|
||||
ids.uniq.map { |id| definition[:rows][id]&.dup }.compact
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns matching row dups for an IN-list lookup (`where(column:
|
||||
# [v1, v2, ...])`), the same "nil means scan" contract as
|
||||
# #lookup_by_index/#lookup_by_index_range. This is what every
|
||||
# eager-loading batch query goes through
|
||||
# (Relation#preload_belongs_to's `where(id: owner_ids)`,
|
||||
# #preload_has_one/#preload_has_many/#preload_has_many_through's
|
||||
# `where(foreign_key: owner_ids)`) - without it, `includes(...)` on
|
||||
# any association was always a full table scan even though it only
|
||||
# ever needed a handful of specific ids, no matter how many other
|
||||
# secondary indexes existed elsewhere.
|
||||
#
|
||||
# `id` is always treated as indexed, with zero add_index calls
|
||||
# needed: #rows is already a Hash keyed by id, so this is just direct
|
||||
# O(1)-per-value hash lookups, same as #find. Any other column needs
|
||||
# a real single-column index (composite indexes can't answer an
|
||||
# IN-list on just one of their columns - which specific OTHER column
|
||||
# value would pair with each id in the list is ambiguous).
|
||||
def lookup_by_index_in(table, column, values)
|
||||
column = column.to_s
|
||||
values = values.uniq
|
||||
|
||||
if column == 'id'
|
||||
instrument('SELECT INDEX IN', table, columns: ['id']) do
|
||||
rows_by_id(table, values)
|
||||
end
|
||||
else
|
||||
index = within_transaction(read_only: true) do
|
||||
(table_definition(table)[:indexes] || {}).values.find { |candidate| candidate[:columns] == [column] }
|
||||
end
|
||||
return nil unless index
|
||||
|
||||
instrument('SELECT INDEX IN', table, columns: index[:columns]) do
|
||||
within_transaction(read_only: true) do
|
||||
definition = table_definition(table)
|
||||
ids = values.flat_map { |value| definition[:indexes][index[:name]][:map][[value]] || [] }
|
||||
ids.uniq.map { |id| definition[:rows][id]&.dup }.compact
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def within_transaction(read_only: false)
|
||||
|
||||
Reference in new issue
Block a user