require 'ostruct'
module SchemaAssociations
module ActiveRecord
module Relation
def initialize(klass, *args, **kwargs)
klass.send :_load_schema_associations_associations unless klass.nil?
super
end
end
module Base
module ClassMethods
def reflections(*args)
_load_schema_associations_associations
super
end
def reflect_on_association(*args)
_load_schema_associations_associations
super
end
# introduced in rails 4.1
def _reflect_on_association(*args)
_load_schema_associations_associations
super
end
def reflect_on_all_associations(*args)
_load_schema_associations_associations
super
end
def define_attribute_methods(*args)
super
_load_schema_associations_associations
end
# Per-model override of Config options. Use via, e.g.
# class MyModel < ActiveRecord::Base
# schema_associations :auto_create => false
# end
#
# If :auto_create is not specified, it is implicitly
# specified as true. This allows the "non-invasive" style of using
# SchemaAssociations in which you set the global Config to
# auto_create = false, then in any model that you want auto
# associations you simply do:
#
# class MyModel < ActiveRecord::Base
# schema_associations
# end
#
# Of course other options can be passed, such as
#
# class MyModel < ActiveRecord::Base
# schema_associations :concise_names => false, :except_type => :has_and_belongs_to_many
# end
#
def schema_associations(opts={})
@schema_associations_config = SchemaAssociations.config.merge({:auto_create => true}.merge(opts))
end
def schema_associations_config # :nodoc:
@schema_associations_config ||= SchemaAssociations.config.dup
end
%i[has_many has_one].each do |m|
define_method(m) do |name, *args, **options|
if @schema_associations_associations_loaded
super name, *args, **options
else
@schema_associations_deferred_associations ||= []
@schema_associations_deferred_associations.push({macro: m, name: name, args: args, options: options})
end
end
end
private
def _load_schema_associations_associations
return if @schema_associations_associations_loaded
return if abstract_class?
return unless schema_associations_config.auto_create?
@schema_associations_associations_loaded = :loading
reverse_foreign_keys.each do | foreign_key |
if foreign_key.from_table =~ /^#{table_name}_(.*)$/ || foreign_key.from_table =~ /^(.*)_#{table_name}$/
other_table = $1
if other_table == other_table.pluralize and connection.columns(foreign_key.from_table).any?{|col| col.name == "#{other_table.singularize}_id"}
_define_association(:has_and_belongs_to_many, foreign_key, other_table)
else
_define_association(:has_one_or_many, foreign_key)
end
else
_define_association(:has_one_or_many, foreign_key)
end
end
foreign_keys.each do | foreign_key |
_define_association(:belongs_to, foreign_key)
end
(@schema_associations_deferred_associations || []).each do |a|
argstr = a[:args].inspect[1...-1] + ' # deferred association'
_create_association(a[:macro], a[:name], argstr, *a[:args], **a[:options])
end
if instance_variable_defined? :@schema_associations_deferred_associations
remove_instance_variable :@schema_associations_deferred_associations
end
@schema_associations_associations_loaded = true
end
def _define_association(macro, fk, referencing_table_name = nil)
column_names = Array.wrap(fk.column)
return unless column_names.size == 1
referencing_table_name ||= fk.from_table
column_name = column_names.first
references_name = fk.to_table.singularize
referencing_name = referencing_table_name.singularize
referencing_class_name = _get_class_name(referencing_name)
references_class_name = _get_class_name(references_name)
names = _determine_association_names(column_name.sub(/_id$/, ''), referencing_name, references_name)
argstr = ""
case macro
when :has_and_belongs_to_many
name = names[:has_many]
opts = {:class_name => referencing_class_name, :join_table => fk.from_table, :foreign_key => column_name}
when :belongs_to
name = names[:belongs_to]
opts = {:class_name => references_class_name, :foreign_key => column_name}
if connection.indexes(referencing_table_name).any?{|index| index.unique && index.columns == [column_name]}
opts[:inverse_of] = names[:has_one]
else
opts[:inverse_of] = names[:has_many]
end
when :has_one_or_many
opts = {:class_name => referencing_class_name, :foreign_key => column_name, :inverse_of => names[:belongs_to]}
# use connection.indexes and connection.colums rather than class
# methods of the referencing class because using the class
# methods would require getting the class -- which might trigger
# an autoload which could start some recursion making things much
# harder to debug.
if connection.indexes(referencing_table_name).any?{|index| index.unique && index.columns == [column_name]}
macro = :has_one
name = names[:has_one]
else
macro = :has_many
name = names[:has_many]
if connection.columns(referencing_table_name).any?{ |col| col.name == 'position' }
scope_block = lambda { order :position }
argstr += "-> { order :position }, "
end
end
end
argstr += opts.inspect[1...-1]
if (_filter_association(macro, name) && !_method_exists?(name))
_create_association(macro, name, argstr, scope_block, **opts.dup)
end
end
def _create_association(macro, name, argstr, *args, **options)
logger.debug "[schema_associations] #{self.name || self.from_table.classify}.#{macro} #{name.inspect}, #{argstr}"
send macro, name, *args, **options
case
when respond_to?(:subclasses) then subclasses
end.each do |subclass|
subclass.send :_create_association, macro, name, argstr, *args, **options
end
end
def _determine_association_names(reference_name, referencing_name, references_name)
references_concise = _concise_name(references_name, referencing_name)
referencing_concise = _concise_name(referencing_name, references_name)
if _use_concise_name?
references = references_concise
referencing = referencing_concise
else
references = references_name
referencing = referencing_name
end
case reference_name
when 'parent'
belongs_to = 'parent'
has_one = 'child'
has_many = 'children'
when references_name
belongs_to = references
has_one = referencing
has_many = referencing.pluralize
when /(.*)_#{references_name}$/, /(.*)_#{references_concise}$/
label = $1
belongs_to = "#{label}_#{references}"
has_one = "#{referencing}_as_#{label}"
has_many = "#{referencing.pluralize}_as_#{label}"
when /^#{references_name}_(.*)$/, /^#{references_concise}_(.*)$/
label = $1
belongs_to = "#{references}_#{label}"
has_one = "#{referencing}_as_#{label}"
has_many = "#{referencing.pluralize}_as_#{label}"
else
belongs_to = reference_name
has_one = "#{referencing}_as_#{reference_name}"
has_many = "#{referencing.pluralize}_as_#{reference_name}"
end
{ :belongs_to => belongs_to.to_sym, :has_one => has_one.to_sym, :has_many => has_many.to_sym }
end
def _concise_name(string, other)
case
when string =~ /^#{other}_(.*)$/ then $1
when string =~ /(.*)_#{other}$/ then $1
when leader = _common_leader(string,other) then string[leader.length, string.length-leader.length]
else string
end
end
def _common_leader(string, other)
leader = nil
other.split('_').each do |part|
test = "#{leader}#{part}_"
break unless string.start_with? test
leader = test
end
return leader
end
def _use_concise_name?
schema_associations_config.concise_names?
end
def _filter_association(macro, name)
config = schema_associations_config
return false if config.only and not Array.wrap(config.only).include?(name)
return false if config.except and Array.wrap(config.except).include?(name)
return false if config.only_type and not Array.wrap(config.only_type).include?(macro)
return false if config.except_type and Array.wrap(config.except_type).include?(macro)
return true
end
def _get_class_name(name)
name = name.dup
found = schema_associations_config.table_prefix_map.find { |table_prefix, class_prefix|
name.sub! %r[\A#{table_prefix}], ''
}
name = name.classify
name = found.last + name if found
name
end
def _method_exists?(name)
method_defined?(name) || private_method_defined?(name) and not (name == :type && [Object, Kernel].include?(instance_method(:type).owner))
end
end
end
end
end