1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439
|
# 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 <tt>:key_map</tt>
# option as a hash, instead of having it inferred from the <tt>:model_map</tt>,
# 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
|