Skip to content

Instantly share code, notes, and snippets.

@pboling
Last active August 29, 2015 14:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pboling/f908252811e1e7cc5427 to your computer and use it in GitHub Desktop.
Save pboling/f908252811e1e7cc5427 to your computer and use it in GitHub Desktop.
Concern::NestedFilters
module Concern
module NestedFilters
extend ActiveSupport::Concern
CACHE_EXPIRATION = 1.days
NULL_SORT = "LAST"
NestedFilter = Struct.new(:filter_klass, :filter_param_key, :filter_name, :filter_options)
NestedFilterOption = Struct.new(:id, :name)
included do
class_attribute :nested_filter_cache_expiration
class_attribute :nested_filter_names
class_attribute :nested_filter_sorts
class_attribute :nested_filter_null_clause
scope :nested_filter_from_params, lambda {|params| where(arel_query_hash_from_params(params)) }
end
module ClassMethods
# filters param can be like any of the following, which are all handled by the Array() cast
# 1. "type" => a single value
# 2. ["type", "status"] => two filters, no sort preference
# 3. { => nested sort with sort preferences (Note: Must be an ordered Hash a la Ruby 1.9+)
# "type" => "ASC",
# "status" => "DESC",
# }
# nested_filters_raw will look like:
# 1. ["type"]
# 2. ["type", "status"]
# 3. [["type", "ASC"],["status", "DESC"]]
def has_nested_filters(filters, cache_expiration: CACHE_EXPIRATION, null_sort: NULL_SORT)
nested_filters_raw = Array(filters).compact
self.nested_filter_names = nested_filters_raw.map {|x| Array(x)[0]}
self.nested_filter_sorts = nested_filters_raw.map {|x| Array(x)[1] || "ASC"}
self.nested_filter_cache_expiration = cache_expiration
self.nested_filter_null_clause = null_sort ? " NULLS #{null_sort}" : ""
end
def nested_filters
Rails.cache.fetch(cache_key(base: "nested-filters"), :expires_in => nested_filter_cache_expiration) do
self.connection.execute("select DISTINCT #{distinct_sql} from #{table_name} ORDER BY #{order_sql}").values.inject({}) do |hash, n_tuple|
build_nested_hash(hash, n_tuple)
end
end
end
# nest - an array of values, for example
# if the top level filter is "type",
# then the first index of nest might be "User"
# if the next level filter is "status",
# then the second index of nest might be "active"
def cache_key(base:, nest: [])
"#{self.model_name.param_key}-#{base}#{cache_for_nest(nest: nest)}"
end
def cache_for_nest(nest: [])
nest.any? ?
"-#{nest.join("_")}" :
""
end
def nested_filters_at(nest: [])
Rails.cache.fetch(cache_key(base: "nested-filters-at", nest: nest), :expires_in => nested_filter_cache_expiration) do
nested_lookup(nested_filters, nest: nest)
end
end
def reverse_nested_filters_at(params: {})
nest_indexes = sort_params_into_nested_indexes(params)
reverse_nested_lookup(nested_filters_at, nest: [], nest_indexes: nest_indexes)
end
private
def sort_params_to_tuples(params)
params ||= {}
params.select {|k,v| nested_filter_names.include?(k) }.sort_by {|key, value| nested_filter_names.index(key) }
end
def sort_params_into_nested_indexes(params)
sort_params_to_tuples(params).to_h.values
end
def arel_query_hash_from_params(params)
query_hash_from_tuples(tuples: sort_params_to_tuples(params))
end
def distinct_sql
nested_filter_names.compact.join(", ")
end
def order_sql
[nested_filter_names, nested_filter_sorts].transpose.map {|x| x.join(" ")}.join(", ") << nested_filter_null_clause
end
def build_nested_hash(hash, n_tuple)
key = n_tuple.
shift.
to_s # nil guard
if n_tuple.length == 1
hash[key] ||= []
hash[key] << n_tuple[0]
else
hash[key] ||= {}
hash[key].merge!(build_nested_hash(hash[key], n_tuple))
end
hash
end
# returns a NestedFilter struct or nil
def nested_lookup(hash, nest: nest, level: 0)
# stop the recursion when needed
if nest.blank?
# When nest has been exhausted
if hash.is_a?(Hash)
return NestedFilter.new(model_name, model_name.param_key, nested_filter_names[level], nested_filter_options(hash.keys))
else
return NestedFilter.new(model_name, model_name.param_key, nested_filter_names[level], nested_filter_options(hash))
end
else
# Traversed down to a non-hash leaf node, and nest is not blank, then nil is the result of the lookup
return nil unless hash.is_a?(Hash)
end
nested_lookup(hash[nest[0]], nest: nest[1..-1], level: level+1)
end
# returns an array of NestedFilterOption structs
def nested_filter_options(array)
array.map.with_index do |value, index|
NestedFilterOption.new(index, value)
end
end
def reverse_nested_lookup(nfilter = nested_filters_at, nest: [], nest_indexes: [], level: 0)
selected_filter_option_index = nest_indexes[0].to_i
return nfilter if nest_indexes.blank? || nfilter.nil? || selected_filter_option_index > (nfilter.filter_options.length - 1)
nest << nfilter.filter_options[selected_filter_option_index].name
# puts "[reverse_nested_lookup] #{level}: Reverse Nest: #{nest.class} #{nest}, Nest Indexes: #{nest_indexes.class} #{nest_indexes}"
reverse_nested_lookup(nested_filters_at(nest: nest), nest: nest, nest_indexes: nest_indexes[1..-1], level: level+1)
end
def query_hash_from_tuples(nfilter = nested_filters_at, nest: [], tuples: [], query_hash: {}, level: 0)
tuple = tuples.shift
return query_hash if tuple.blank? || nfilter.nil?
selected_filter_option_index = tuple[1].to_i
return query_hash if selected_filter_option_index > (nfilter.filter_options.length - 1)
query_hash[tuple[0].to_s] = nfilter.filter_options[selected_filter_option_index].name
nest << nfilter.filter_options[selected_filter_option_index].name
# puts "[reverse_from_tuples] #{level}: tuple: #{tuple}, tuples: #{tuples}, nest: #{nest.inspect}, query_hash: #{query_hash.inspect}, filter_name: #{nfilter.filter_name}"
query_hash_from_tuples(nested_filters_at(nest: nest), nest: nest, tuples: tuples, query_hash: query_hash, level: level+1)
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment