feat(orm): extend store with add_index and remove_index

This commit is contained in:
dualfroz committed 2026-09-08 20:03:48 +02:00
1 parent 7a8dcd388c
commit c422c8b367
1 file changed
+204
+204
View File
@@ -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)