lib/chrono_model/adapter/indexes.rb
# frozen_string_literal: true
module ChronoModel
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
module Indexes
# Create temporal indexes for timestamp search.
#
# This index is used by +TimeMachine.at+, `.current` and `.past` to
# build the temporal WHERE clauses that fetch the state of records at
# a single point in time.
#
# Parameters:
#
# `table`: the table where to create indexes on
# `range`: the tsrange field
#
# Options:
#
# `:name`: the index name prefix, defaults to
# index_{table}_temporal_on_{range / lower_range / upper_range}
#
def add_temporal_indexes(table, range, options = {})
range_idx, lower_idx, upper_idx =
temporal_index_names(table, range, options)
chrono_alter_index(table, options) do
execute <<-SQL.squish
CREATE INDEX #{range_idx} ON #{table} USING gist ( #{range} )
SQL
# Indexes used for precise history filtering, sorting and, in history
# tables, by UPDATE / DELETE triggers.
#
execute "CREATE INDEX #{lower_idx} ON #{table} ( lower(#{range}) )"
execute "CREATE INDEX #{upper_idx} ON #{table} ( upper(#{range}) )"
end
end
def remove_temporal_indexes(table, range, options = {})
indexes = temporal_index_names(table, range, options)
chrono_alter_index(table, options) do
indexes.each { |idx| execute "DROP INDEX #{idx}" }
end
end
# Adds an EXCLUDE constraint to the given table, to assure that
# no more than one record can occupy a definite segment on a
# timeline.
#
def add_timeline_consistency_constraint(table, range, options = {})
name = timeline_consistency_constraint_name(table)
id = options[:id] || primary_key(table)
chrono_alter_constraint(table, options) do
execute <<-SQL.squish
ALTER TABLE #{table} ADD CONSTRAINT #{name}
EXCLUDE USING gist ( #{id} WITH =, #{range} WITH && )
SQL
end
end
def remove_timeline_consistency_constraint(table, options = {})
name = timeline_consistency_constraint_name(table)
chrono_alter_constraint(table, options) do
execute <<-SQL.squish
ALTER TABLE #{table} DROP CONSTRAINT #{name}
SQL
end
end
def timeline_consistency_constraint_name(table)
"#{table}_timeline_consistency"
end
private
# Creates indexes for a newly made history table
#
def chrono_create_history_indexes_for(table, p_pkey)
add_temporal_indexes table, :validity, on_current_schema: true
execute "CREATE INDEX #{table}_inherit_pkey ON #{table} ( #{p_pkey} )"
execute "CREATE INDEX #{table}_recorded_at ON #{table} ( recorded_at )"
execute "CREATE INDEX #{table}_instance_history ON #{table} ( #{p_pkey}, recorded_at )"
end
# Rename indexes on history schema
#
def chrono_rename_history_indexes(name, new_name)
on_history_schema do
standard_index_names = %w[
inherit_pkey instance_history pkey
recorded_at timeline_consistency
]
old_names = temporal_index_names(name, :validity) +
standard_index_names.map { |i| "#{name}_#{i}" }
new_names = temporal_index_names(new_name, :validity) +
standard_index_names.map { |i| "#{new_name}_#{i}" }
old_names.zip(new_names).each do |old, new|
execute "ALTER INDEX #{old} RENAME TO #{new}"
end
end
end
# Rename indexes on temporal schema
#
def chrono_rename_temporal_indexes(name, new_name)
on_temporal_schema do
temporal_indexes = indexes(new_name)
temporal_indexes.map(&:name).each do |old_idx_name|
if old_idx_name =~ /^index_#{name}_on_(?<columns>.+)/
new_idx_name = "index_#{new_name}_on_#{$~['columns']}"
execute "ALTER INDEX #{old_idx_name} RENAME TO #{new_idx_name}"
end
end
end
end
# Copy the indexes from the temporal table to the history table
# if the indexes are not already created with the same name.
#
# Uniqueness is voluntarily ignored, as it doesn't make sense on
# history tables.
#
# Used in migrations.
#
# Ref: GitHub pull #21.
#
def chrono_copy_indexes_to_history(table_name)
history_indexes = on_history_schema { indexes(table_name) }.map(&:name)
temporal_indexes = on_temporal_schema { indexes(table_name) }
temporal_indexes.each do |index|
next if history_indexes.include?(index.name)
on_history_schema do
# index.columns is an Array for plain indexes,
# while it is a String for computed indexes.
#
columns = Array.wrap(index.columns).join(', ')
execute %[
CREATE INDEX #{index.name} ON #{table_name}
USING #{index.using} ( #{columns} )
], 'Copy index from temporal to history'
end
end
end
# Returns a suitable index name on the given table and for the
# given range definition.
#
def temporal_index_names(table, range, options = {})
prefix = options[:name].presence || "index_#{table}_temporal"
# When creating computed indexes
#
# e.g. ends_on::timestamp + time '23:59:59'
#
# remove everything following the field name.
range = range.to_s.sub(/\W.*/, '')
[range, "lower_#{range}", "upper_#{range}"].map do |suffix|
"#{prefix}_on_#{suffix}"
end
end
# Generic alteration of history tables, where changes have to be
# propagated both on the temporal table and the history one.
#
# Internally, the :on_current_schema bypasses the +is_chrono?+
# check, as some temporal indexes and constraints are created
# only on the history table, and the creation methods already
# run scoped into the correct schema.
#
def chrono_alter_index(table_name, options, &block)
if is_chrono?(table_name) && !options[:on_current_schema]
on_temporal_schema(&block)
on_history_schema(&block)
else
yield
end
end
def chrono_alter_constraint(table_name, options, &block)
if is_chrono?(table_name) && !options[:on_current_schema]
on_temporal_schema(&block)
else
yield
end
end
end
end
end