lib/deep_pluck/model.rb
# frozen_string_literal: true
require 'rails_compatibility'
require 'rails_compatibility/unscope_where'
require 'rails_compatibility/build_joins'
require 'deep_pluck/data_combiner'
module DeepPluck
class Model
# ----------------------------------------------------------------
# ● Initialize
# ----------------------------------------------------------------
def initialize(relation, parent_association_key = nil, parent_model = nil, need_columns: [])
if relation.is_a?(ActiveRecord::Base)
@model = relation
@relation = nil
@klass = @model.class
else
@model = nil
@relation = relation
@klass = @relation.klass
end
@parent_association_key = parent_association_key
@parent_model = parent_model
@need_columns = need_columns
@associations = {}
end
# ----------------------------------------------------------------
# ● Reader
# ----------------------------------------------------------------
def get_reflect(association_key)
@klass.reflect_on_association(association_key.to_sym) || # add to_sym since rails 3 only support symbol
fail(ActiveRecord::ConfigurationError, "ActiveRecord::ConfigurationError: Association named \
'#{association_key}' was not found on #{@klass.name}; perhaps you misspelled it?"
)
end
def with_conditions(reflect, relation)
options = reflect.options
relation = relation.instance_exec(&reflect.scope) if reflect.respond_to?(:scope) and reflect.scope
relation = relation.where(options[:conditions]) if options[:conditions]
return relation
end
def get_join_table(reflect)
options = reflect.options
return options[:through] if options[:through]
return (options[:join_table] || reflect.send(:derive_join_table)) if reflect.macro == :has_and_belongs_to_many
return nil
end
def get_primary_key(reflect)
options = reflect.options
return options[:primary_key] if options[:primary_key]
return (reflect.belongs_to? ? reflect.klass : reflect.active_record).primary_key
end
def get_foreign_key(reflect, reverse: false, with_table_name: false)
reflect = reflect.chain.last
if reverse and (table_name = get_join_table(reflect)) # reverse = parent
key = reflect.chain.last.foreign_key
else
key = (reflect.belongs_to? == reverse ? get_primary_key(reflect) : reflect.foreign_key)
table_name = (reverse ? reflect.klass : reflect.active_record).table_name
end
return "#{table_name}.#{key}" if with_table_name
return key.to_s # key may be symbol if specify foreign_key in association options
end
def get_association_scope(reflect)
owner = reflect.active_record.new
return RailsCompatibility.unscope_where(reflect.association_class.new(owner, reflect).send(:association_scope))
end
def use_association_to_query?(reflect)
reflect.through_reflection && reflect.chain.first.macro == :has_one
end
# ----------------------------------------------------------------
# ● Contruction OPs
# ----------------------------------------------------------------
private
def add_need_column(column)
@need_columns << column
end
def add_association(hash)
hash.each do |key, value|
model = (@associations[key] ||= Model.new(get_reflect(key).klass.where(''), key, self))
model.add(value)
end
end
public
def add(args)
return self if args == nil
args = [args] if not args.is_a?(Array)
args.each do |arg|
case arg
when Hash ; add_association(arg)
else ; add_need_column(arg)
end
end
return self
end
# ----------------------------------------------------------------
# ● Load
# ----------------------------------------------------------------
private
def do_query(parent, reflect, relation)
parent_key = get_foreign_key(reflect)
relation_key = get_foreign_key(reflect, reverse: true, with_table_name: true)
ids = parent.map{|s| s[parent_key] }
ids.uniq!
ids.compact!
relation = with_conditions(reflect, relation)
query = { relation_key => ids }
query[reflect.type] = reflect.active_record.to_s if reflect.type
return get_association_scope(reflect).where(query) if use_association_to_query?(reflect)
joins = if reflect.macro == :has_and_belongs_to_many
RailsCompatibility.build_joins(reflect, relation)[0]
else
backtrace_possible_association(relation, get_join_table(reflect))
end
return relation.joins(joins).where(query)
end
# Let city has_many :users, through: :schools
# And the query is: City.deep_pluck('users' => :name)
# We want to get the users data via `User.joins(:school).where(city_id: city_ids)`
# But get_join_table(reflect) returns `:schools` not :school
# No idea how to get the right association, so we try singularize or pluralize it.
def backtrace_possible_association(relation, join_table)
return join_table if join_table and relation.reflect_on_association(join_table)
join_table.to_s.singularize.to_sym.tap{|s| return s if relation.reflect_on_association(s) }
join_table.to_s.pluralize.to_sym.tap{|s| return s if relation.reflect_on_association(s) }
return nil
end
def set_includes_data(parent, column_name, model)
reflect = get_reflect(column_name)
reverse = !reflect.belongs_to?
foreign_key = get_foreign_key(reflect, reverse: reverse)
primary_key = get_foreign_key(reflect, reverse: !reverse)
children = model.load_data{|relation| do_query(parent, reflect, relation) }
# reverse = false: Child.where(:id => parent.pluck(:child_id))
# reverse = true : Child.where(:parent_id => parent.pluck(:id))
return DataCombiner.combine_data(
parent,
children,
primary_key,
column_name,
foreign_key,
reverse,
reflect.collection?,
)
end
def get_query_columns
if @parent_model
parent_reflect = @parent_model.get_reflect(@parent_association_key)
prev_need_columns = @parent_model.get_foreign_key(parent_reflect, reverse: true, with_table_name: true)
end
next_need_columns = @associations.map{|key, _| get_foreign_key(get_reflect(key), with_table_name: true) }.uniq
return [*prev_need_columns, *next_need_columns, *@need_columns].uniq(&Helper::TO_KEY_PROC)
end
def pluck_values(columns)
includes_values = @relation.includes_values
@relation.includes_values = []
result = @relation.pluck_all(*columns)
@relation.includes_values = includes_values
return result
end
def loaded_models
return [@model] if @model
return @relation if @relation.loaded
end
public
def load_data
columns = get_query_columns
key_columns = columns.map(&Helper::TO_KEY_PROC)
@relation = yield(@relation) if block_given?
@data = loaded_models ? loaded_models.as_json(root: false, only: key_columns) : pluck_values(columns)
if @data.size != 0
# for delete_extra_column_data!
@extra_columns = key_columns - @need_columns.map(&Helper::TO_KEY_PROC)
@associations.each do |key, model|
set_includes_data(@data, key, model)
end
end
return @data
end
def load_all
load_data
delete_extra_column_data!
return @data
end
def delete_extra_column_data!
return if @data.blank?
@data.each{|s| s.except!(*@extra_columns) }
@associations.each{|_, model| model.delete_extra_column_data! }
end
# ----------------------------------------------------------------
# ● Helper methods
# ----------------------------------------------------------------
module Helper
TO_KEY_PROC = proc{|s| Helper.column_to_key(s) }
def self.column_to_key(key) # user_achievements.user_id => user_id
key = key[/(\w+)[^\w]*\z/]
key.gsub!(/[^\w]+/, '')
return key
end
end
end
end