# frozen-string-literal: true
module Sequel
module Plugins
# = Overview
#
# The class_table_inheritance plugin uses the single_table_inheritance
# plugin, so it supports all of the single_table_inheritance features, but it
# additionally supports subclasses that have additional columns,
# which are stored in a separate table with a key referencing the primary table.
#
# = Detail
#
# For example, with this hierarchy:
#
# Employee
# / \
# Staff Manager
# | |
# Cook Executive
# |
# CEO
#
# the following database schema may be used (table - columns):
#
# employees :: id, name, kind
# staff :: id, manager_id
# managers :: id, num_staff
# executives :: id, num_managers
#
# The class_table_inheritance plugin assumes that the root table
# (e.g. employees) has a primary key column (usually autoincrementing),
# and all other tables have a foreign key of the same name that points
# to the same column in their superclass's table, which is also the primary
# key for that table. In this example, the employees table has an id column
# is a primary key and the id column in every other table is a foreign key
# referencing employees.id, which is also the primary key of that table.
#
# Additionally, note that other than the primary key column, no subclass
# table has a column with the same name as any superclass table. This plugin
# does not support cases where the column names in a subclass table overlap
# with any column names in a superclass table.
#
# In this example the staff table also stores Cook model objects and the
# executives table also stores CEO model objects.
#
# When using the class_table_inheritance plugin, subclasses that have additional
# columns use joined datasets in subselects:
#
# Employee.dataset.sql
# # SELECT * FROM employees
#
# Manager.dataset.sql
# # SELECT * FROM (
# # SELECT employees.id, employees.name, employees.kind,
# # managers.num_staff
# # FROM employees
# # JOIN managers ON (managers.id = employees.id)
# # ) AS employees
#
# CEO.dataset.sql
# # SELECT * FROM (
# # SELECT employees.id, employees.name, employees.kind,
# # managers.num_staff, executives.num_managers
# # FROM employees
# # JOIN managers ON (managers.id = employees.id)
# # JOIN executives ON (executives.id = managers.id)
# # WHERE (employees.kind IN ('CEO'))
# # ) AS employees
#
# This allows CEO.all to return instances with all attributes
# loaded. The plugin overrides the deleting, inserting, and updating
# in the model to work with multiple tables, by handling each table
# individually.
#
# = Subclass loading
#
# When model objects are retrieved for a superclass the result can contain
# subclass instances that only have column entries for the columns in the
# superclass table. Calling the column method on the subclass instance for
# a column not in the superclass table will cause a query to the database
# to get the value for that column. If the subclass instance was retreived
# using Dataset#all, the query to the database will attempt to load the column
# values for all subclass instances that were retrieved. For example:
#
# a = Employee.all # [<#Staff>, <#Manager>, <#Executive>]
# a.first.values # {:id=>1, name=>'S', :kind=>'Staff'}
# a.first.manager_id # Loads the manager_id attribute from the database
#
# If you want to get all columns in a subclass instance after loading
# via the superclass, call Model#refresh.
#
# a = Employee.first
# a.values # {:id=>1, name=>'S', :kind=>'CEO'}
# a.refresh.values # {:id=>1, name=>'S', :kind=>'CEO', :num_staff=>4, :num_managers=>2}
#
# You can also load directly from a subclass:
#
# a = Executive.first
# a.values # {:id=>1, name=>'S', :kind=>'Executive', :num_staff=>4, :num_managers=>2}
#
# Note that when loading from a subclass, because the subclass dataset uses a subquery
# that by default uses the same alias at the primary table, any qualified identifiers
# should reference the subquery alias (and qualified identifiers should not be needed
# unless joining to another table):
#
# a = Executive.where(id: 1).first # works
# a = Executive.where{{employees[:id]=>1}}.first # works
# a = Executive.where{{executives[:id]=>1}}.first # doesn't work
#
# Note that because subclass datasets select from a subquery, you cannot update,
# delete, or insert into them directly. To delete related rows, you need to go
# through the related tables and remove the related rows. Code that does this would
# be similar to:
#
# pks = Executive.where{num_staff < 10}.select_map(:id)
# Executive.cti_tables.reverse_each do |table|
# DB.from(table).where(id: pks).delete
# end
#
# = Usage
#
# # Use the default of storing the class name in the sti_key
# # column (:kind in this case)
# class Employee < Sequel::Model
# plugin :class_table_inheritance, key: :kind
# end
#
# # Have subclasses inherit from the appropriate class
# class Staff < Employee; end # uses staff table
# class Cook < Staff; end # cooks table doesn't exist so uses staff table
# class Manager < Employee; end # uses managers table
# class Executive < Manager; end # uses executives table
# class CEO < Executive; end # ceos table doesn't exist so uses executives table
#
# # Some examples of using these options:
#
# # Specifying the tables with a :table_map hash
# Employee.plugin :class_table_inheritance,
# table_map: {Employee: :employees,
# Staff: :staff,
# Cook: :staff,
# Manager: :managers,
# Executive: :executives,
# CEO: :executives }
#
# # Using integers to store the class type, with a :model_map hash
# # and an sti_key of :type
# Employee.plugin :class_table_inheritance, key: :type,
# model_map: {1=>:Staff, 2=>:Cook, 3=>:Manager, 4=>:Executive, 5=>:CEO}
#
# # Using non-class name strings
# Employee.plugin :class_table_inheritance, key: :type,
# model_map: {'staff'=>:Staff, 'cook staff'=>:Cook, 'supervisor'=>:Manager}
#
# # By default the plugin sets the respective column value
# # when a new instance is created.
# Cook.create.type == 'cook staff'
# Manager.create.type == 'supervisor'
#
# # You can customize this behavior with the :key_chooser option.
# # This is most useful when using a non-bijective mapping.
# Employee.plugin :class_table_inheritance, key: :type,
# model_map: {'cook staff'=>:Cook, 'supervisor'=>:Manager},
# key_chooser: proc{|instance| instance.model.sti_key_map[instance.model.to_s].first || 'stranger' }
#
# # Using custom procs, with :model_map taking column values
# # and yielding either a class, string, symbol, or nil,
# # and :key_map taking a class object and returning the column
# # value to use
# Employee.plugin :single_table_inheritance, key: :type,
# model_map: proc{|v| v.reverse},
# key_map: proc{|klass| klass.name.reverse}
#
# # You can use the same class for multiple values.
# # This is mainly useful when the sti_key column contains multiple values
# # which are different but do not require different code.
# Employee.plugin :single_table_inheritance, key: :type,
# model_map: {'staff' => "Staff",
# 'manager' => "Manager",
# 'overpayed staff' => "Staff",
# 'underpayed staff' => "Staff"}
#
# One minor issue to note is that if you specify the :key_map
# option as a hash, instead of having it inferred from the :model_map,
# you should only use class name strings as keys, you should not use symbols
# as keys.
module ClassTableInheritance
# The class_table_inheritance plugin requires the single_table_inheritance
# plugin and the lazy_attributes plugin to handle lazily-loaded attributes
# for subclass instances returned by superclass methods.
def self.apply(model, opts = OPTS)
model.plugin :single_table_inheritance, nil
model.plugin :lazy_attributes
end
# Initialize the plugin using the following options:
# :alias :: Change the alias used for the subquery in model datasets.
# using this as the alias.
# :key :: Column symbol that holds the key that identifies the class to use.
# Necessary if you want to call model methods on a superclass
# that return subclass instances
# :model_map :: Hash or proc mapping the key column values to model class names.
# :key_map :: Hash or proc mapping model class names to key column values.
# Each value or return is an array of possible key column values.
# :key_chooser :: proc returning key for the provided model instance
# :table_map :: Hash with class name symbols keys mapping to table name symbol values.
# Overrides implicit table names.
# :ignore_subclass_columns :: Array with column names as symbols that are ignored
# on all sub-classes.
# :qualify_tables :: Boolean true to qualify automatically determined
# subclass tables with the same qualifier as their
# superclass.
def self.configure(model, opts = OPTS)
SingleTableInheritance.configure model, opts[:key], opts
model.instance_exec do
@cti_models = [self]
@cti_tables = [table_name]
@cti_instance_dataset = @instance_dataset
@cti_table_columns = columns
@cti_table_map = opts[:table_map] || {}
@cti_alias = opts[:alias] || case source = @dataset.first_source
when SQL::QualifiedIdentifier
@dataset.unqualified_column_for(source)
else
source
end
@cti_ignore_subclass_columns = opts[:ignore_subclass_columns] || []
@cti_qualify_tables = !!opts[:qualify_tables]
end
end
module ClassMethods
# An array of each model in the inheritance hierarchy that is
# backed by a new table.
attr_reader :cti_models
# An array of column symbols for the backing database table,
# giving the columns to update in each backing database table.
attr_reader :cti_table_columns
# The dataset that table instance datasets are based on.
# Used for database modifications
attr_reader :cti_instance_dataset
# An array of table symbols that back this model. The first is
# table symbol for the base model, and the last is the current model
# table symbol.
attr_reader :cti_tables
# A hash with class name symbol keys and table name symbol values.
# Specified with the :table_map option to the plugin, and should be used if
# the implicit naming is incorrect.
attr_reader :cti_table_map
# An array of columns that may be duplicated in sub-classes. The
# primary key column is always allowed to be duplicated
attr_reader :cti_ignore_subclass_columns
# A boolean indicating whether or not to automatically qualify tables
# backing subclasses with the same qualifier as their superclass, if
# the superclass is qualified. Specified with the :qualify_tables
# option to the plugin and only applied to automatically determined
# table names (not to the :table_map option).
attr_reader :cti_qualify_tables
# Freeze CTI information when freezing model class.
def freeze
@cti_models.freeze
@cti_tables.freeze
@cti_table_columns.freeze
@cti_table_map.freeze
@cti_ignore_subclass_columns.freeze
super
end
Plugins.inherited_instance_variables(self, :@cti_models=>nil, :@cti_tables=>nil, :@cti_table_columns=>nil, :@cti_instance_dataset=>nil, :@cti_table_map=>nil, :@cti_alias=>nil, :@cti_ignore_subclass_columns=>nil, :@cti_qualify_tables=>nil)
# The table name for the current model class's main table.
def table_name
if cti_tables && cti_tables.length > 1
@cti_alias
else
super
end
end
# The name of the most recently joined table.
def cti_table_name
cti_tables.last
end
# The model class for the given key value.
def sti_class_from_key(key)
sti_class(sti_model_map[key])
end
private
def inherited(subclass)
ds = sti_dataset
# Prevent inherited in model/base.rb from setting the dataset
subclass.instance_exec { @dataset = nil }
super
# Set table if this is a class table inheritance
table = nil
columns = nil
if n = subclass.name
if table = cti_table_map[n.to_sym]
columns = db.schema(table).map(&:first)
else
table = if cti_qualify_tables && (schema = dataset.schema_and_table(cti_table_name).first)
SQL::QualifiedIdentifier.new(schema, subclass.implicit_table_name)
else
subclass.implicit_table_name
end
columns = check_non_connection_error(false){db.schema(table) && db.schema(table).map(&:first)}
table = nil if !columns || columns.empty?
end
end
table = nil if table && (table == cti_table_name)
return unless table
pk = primary_key
subclass.instance_exec do
if cti_tables.length == 1
ds = ds.select(*self.columns.map{|cc| Sequel.qualify(cti_table_name, Sequel.identifier(cc))})
end
ds.send(:columns=, self.columns)
cols = (columns - [pk]) - cti_ignore_subclass_columns
dup_cols = cols & ds.columns
unless dup_cols.empty?
raise Error, "class_table_inheritance with duplicate column names (other than the primary key column) is not supported, make sure tables have unique column names (duplicate columns: #{dup_cols}). If this is desired, specify these columns in the :ignore_subclass_columns option when initializing the plugin"
end
sel_app = cols.map{|cc| Sequel.qualify(table, Sequel.identifier(cc))}
@sti_dataset = ds = ds.join(table, pk=>pk).select_append(*sel_app)
ds = ds.from_self(:alias=>@cti_alias)
ds.send(:columns=, self.columns + cols)
set_dataset(ds)
set_columns(self.columns)
@dataset = @dataset.with_row_proc(lambda{|r| subclass.sti_load(r)})
cols.each{|a| define_lazy_attribute_getter(a, :dataset=>dataset, :table=>@cti_alias)}
@cti_models += [self]
@cti_tables += [table]
@cti_table_columns = columns
@cti_instance_dataset = db.from(table)
cti_tables.reverse_each do |ct|
db.schema(ct).each{|sk,v| db_schema[sk] = v}
end
setup_auto_validations if respond_to?(:setup_auto_validations, true)
end
end
# If using a subquery for class table inheritance, also use a subquery
# when setting subclass dataset.
def sti_subclass_dataset(key)
ds = super
if cti_models[0] != self
ds = ds.from_self(:alias=>@cti_alias)
end
ds
end
end
module InstanceMethods
# Delete the row from all backing tables, starting from the
# most recent table and going through all superclasses.
def delete
raise Sequel::Error, "can't delete frozen object" if frozen?
model.cti_models.reverse_each do |m|
cti_this(m).delete
end
self
end
# Set the sti_key column based on the sti_key_map.
def before_validation
if new? && (set = self[model.sti_key])
exp = model.sti_key_chooser.call(self)
if set != exp
set_table = model.sti_class_from_key(set).cti_table_name
exp_table = model.sti_class_from_key(exp).cti_table_name
set_column_value("#{model.sti_key}=", exp) if set_table != exp_table
end
end
super
end
private
def cti_this(model)
use_server(model.cti_instance_dataset.where(model.primary_key_hash(pk)))
end
# Insert rows into all backing tables, using the columns
# in each table.
def _insert
return super if model.cti_models[0] == model
model.cti_models.each do |m|
v = {}
m.cti_table_columns.each{|c| v[c] = @values[c] if @values.include?(c)}
ds = use_server(m.cti_instance_dataset)
if ds.supports_insert_select? && (h = ds.insert_select(v))
@values.merge!(h)
else
nid = ds.insert(v)
@values[primary_key] ||= nid
end
end
@values[primary_key]
end
# Update rows in all backing tables, using the columns in each table.
def _update(columns)
return super if model.cti_models[0] == model
model.cti_models.each do |m|
h = {}
m.cti_table_columns.each{|c| h[c] = columns[c] if columns.include?(c)}
unless h.empty?
ds = cti_this(m)
n = ds.update(h)
raise(NoExistingObject, "Attempt to update object did not result in a single row modification (SQL: #{ds.update_sql(h)})") if require_modification && n != 1
end
end
end
end
end
end
end