lib/hierarchable/hierarchable.rb in hierarchable-0.1.0 vs lib/hierarchable/hierarchable.rb in hierarchable-0.2.0
- old
+ new
@@ -67,16 +67,21 @@
HIERARCHABLE_DEFAULT_PATH_SEPARATOR = '/'
HIERARCHABLE_DEFAULT_RECORD_SEPARATOR = '|'
class_methods do
+ # rubocop:disable Metrics/MethodLength
def hierarchable(opts = {})
class_attribute :hierarchable_config
# Save the configuration
self.hierarchable_config = {
parent_source: opts.fetch(:parent_source, nil),
+ additional_descendant_associations: opts.fetch(
+ :descendant_associations, []
+ ),
+ descendant_associations: opts.fetch(:descendant_associations, nil),
path_separator: opts.fetch(
:path_separator, HIERARCHABLE_DEFAULT_PATH_SEPARATOR
),
record_separator: opts.fetch(
:record_separator, HIERARCHABLE_DEFAULT_RECORD_SEPARATOR
@@ -103,33 +108,39 @@
unless: :new_record?,
if: :hierarchy_parent_changed?
before_create :set_hierarchy_ancestors_path
- scope :descendants_of,
+ scope :hierarchy_descendants_of,
lambda { |object|
where(
'hierarchy_ancestors_path LIKE :hierarchy_ancestors_path',
hierarchy_ancestors_path: "#{object.hierarchy_full_path}%"
)
}
- scope :siblings_of,
+ scope :hierarchy_siblings_of,
lambda { |object|
where(
- 'hierarchy_parent_type=:parent_type AND hierarchy_parent_id=:parent_id',
+ 'hierarchy_parent_type=:parent_type AND ' \
+ 'hierarchy_parent_id=:parent_id',
parent_type: object.hierarchy_parent.class.name,
parent_id: object.hierarchy_parent.id
)
}
include InstanceMethods
end
end
+ # rubocop:enable Metrics/MethodLength
# Instance methods to include
module InstanceMethods
+ def hierarchy_root?
+ hierarchy_root.nil?
+ end
+
def hierarchy_parent(raw: false)
return hierarchy_parent_relationship if raw
# Depending on whether or not the object has been saved or not, we need
# to be smart as to how we try to get the parent. If it's saved, then
@@ -150,10 +161,249 @@
source = hierarchy_parent_source
source.nil? ? nil : send(source)
end
end
+ # Get all of the ancestors models
+ #
+ # The `include_self` parameter can be set to decide where to start the
+ # the ancestry search. If set to `false` (default), then it will return
+ # all models found starting with the parent of this object. If set to
+ # `true`, then it will start with the currect object.
+ def hierarchy_ancestor_models(include_self: false)
+ return [] unless respond_to?(:hierarchy_ancestors_path)
+ return include_self ? [self.class] : [] if hierarchy_ancestors_path.blank?
+
+ models = hierarchy_ancestors_path.split(
+ hierarchable_config[:path_separator]
+ ).map do |ancestor|
+ ancestor_class, = \
+ ancestor.split(hierarchable_config[:record_separator])
+ ancestor_class.safe_constantize
+ end.uniq
+
+ models << self.class if include_self
+ models.uniq
+ end
+
+ # Get ancestors of the same type for an object.
+ #
+ # Using the `hierarchy_ancestors_path`, this will iteratively get all
+ # ancestor objects and return them as a list.
+ #
+ # If the `models` parameter is `:all` (default), then the result
+ # will contain objects of different types. E.g. if we have a Project,
+ # Task, and a Comment, the siblings of a Task may include both Tasks and
+ # Comments. If you only need this one particular model's data, then
+ # set `models` to `:this`. If you want to specify a specific list of models
+ # then that can be passed as a list (e.g. [MyModel1, MyModel2])
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def hierarchy_ancestors(include_self: false, models: :all)
+ return [] unless respond_to?(:hierarchy_ancestors_path)
+ return include_self ? [self] : [] if hierarchy_ancestors_path.blank?
+
+ ancestors = hierarchy_ancestors_path.split(
+ hierarchable_config[:path_separator]
+ ).map do |ancestor|
+ ancestor_class, ancestor_id = ancestor.split(
+ hierarchable_config[:record_separator]
+ )
+
+ next if ancestor_class != self.class.name && models != :all
+ next if models.is_a?(Array) && models.exclude?(ancestor_class)
+
+ ancestor_class.safe_constantize.find(ancestor_id)
+ end
+
+ ancestors.compact
+ ancestors << self if include_self
+ ancestors
+ end
+ # rubocop:enable Metrics/CyclomaticComplexity
+ # rubocop:enable Metrics/PerceivedComplexity
+
+ # Get all of the models of the children that this object could have
+ #
+ # This is based on the models identified in the
+ # `hierarchy_descendant_associations` association
+ #
+ # The `include_self` parameter can be set to decide where to start the
+ # the children search. If set to `false` (default), then it will return
+ # all models found starting with the for all children. If set to
+ # `true`, then it will include the current object's class. Note, this
+ # parameter is added here for consistency, but in the case of children
+ # models, it is unlikely that `include_self` would be set to `true`
+ def hierarchy_children_models(include_self: false)
+ return [] unless respond_to?(:hierarchy_descendant_associations)
+ if hierarchy_descendant_associations.blank?
+ return include_self ? [self.class] : []
+ end
+
+ models = hierarchy_descendant_associations.map do |association|
+ self.association(association)
+ .reflection
+ .class_name
+ .safe_constantize
+ end
+
+ models << self.class if include_self
+ models.uniq
+ end
+
+ # Get all of the sibling models
+ #
+ # The `include_self` parameter can be set to decide what to include in the
+ # sibling models search. If set to `false` (default), then it will return
+ # all models other models that are siblings of the current object. If set to
+ # `true`, then it will also include the current object's class.
+ def hierarchy_sibling_models(include_self: false)
+ return [] unless respond_to?(:hierarchy_parent)
+ return include_self ? [self.class] : [] if hierarchy_parent.blank?
+
+ models = hierarchy_parent.hierarchy_children_models(include_self: false)
+ models << self.class if include_self
+ models.uniq
+ end
+
+ # Get siblings of the same type for an object.
+ #
+ # For a given object type, return all siblings as a hash such that the key
+ # is the model and the value is the list of siblings of that model.
+ #
+ # If the `models` parameter is `:all` (default), then the result
+ # will contain objects of different types. E.g. if we have a Project,
+ # Task, and a Comment, the siblings of a Task may include both Tasks and
+ # Comments. If you only need this one particular model's data, then
+ # set `models` to `:this`. If you want to specify a specific list of models
+ # then that can be passed as a list (e.g. [MyModel1, MyModel2])
+ def hierarchy_siblings(include_self: false, models: :all)
+ return {} unless respond_to?(:hierarchy_parent_id)
+
+ models = case models
+ when Array
+ models
+ when :all
+ hierarchy_sibling_models(include_self: true)
+ else
+ [self.class]
+ end
+
+ result = {}
+ models.each do |model|
+ query = model.where(
+ hierarchy_parent_type: public_send(:hierarchy_parent_type),
+ hierarchy_parent_id: public_send(:hierarchy_parent_id)
+ )
+ query = query.where.not(id:) if model == self.class && !include_self
+ result[model] = query
+ end
+ result
+ end
+
+ # Get all of the descendant models for objects that are descendants of
+ # the current one.
+ #
+ # This will make use of the `hierarchy_descendant_associations` to find
+ # all models.
+ #
+ # Unlike `hierarchy_children_models` that only looks at the immediate
+ # children of an object, this method will look at all descenants of the
+ # current object and find the models. In other words, this will follow
+ # all relationships of all children, and those children's children to
+ # get all models that could potentially be descendants of the current
+ # model.
+ #
+ # The `include_self` parameter can be set to decide where to start the
+ # the descentant search. If set to `false` (default), then it will return
+ # all models found starting with the children of this object. If set to
+ # `true`, then it will start with the currect object.
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def hierarchy_descendant_models(include_self: false)
+ return [] unless respond_to?(:hierarchy_descendant_associations)
+
+ if hierarchy_descendant_associations.blank?
+ return include_self ? [self.class] : []
+ end
+
+ models = []
+ models_to_analyze = [self.class]
+ until models_to_analyze.empty?
+
+ klass = models_to_analyze.pop
+ next if models.include?(klass)
+
+ obj = klass.new
+ next unless obj.respond_to?(:hierarchy_descendant_associations)
+
+ models_to_analyze += obj.hierarchy_children_models(include_self: false)
+
+ next if klass == self.class && !include_self
+
+ models << klass
+ end
+ models.uniq
+ end
+ # rubocop:enable Metrics/CyclomaticComplexity
+ # rubocop:enable Metrics/PerceivedComplexity
+
+ # Get descendants for an object.
+ #
+ # The `include_self` parameter can be set to decide where to start the
+ # the descentant search. If set to `false` (default), then it will return
+ # all models found starting with the children of this object. If set to
+ # `true`, then it will start with the currect object.
+ #
+ # If the `models` parameter is `:all` (default), then the result
+ # will contain objects of different types. E.g. if we have a Project,
+ # Task, and a Comment, the siblings of a Task may include both Tasks and
+ # Comments. If you only need this one particular model's data, then
+ # set `models` to `:this`. If you want to specify a specific list of models
+ # then that can be passed as a list (e.g. [MyModel1, MyModel2])
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def hierarchy_descendants(include_self: false, models: :all)
+ return {} unless respond_to?(:hierarchy_ancestors_path)
+
+ models = case models
+ when Array
+ models
+ when :all
+ hierarchy_descendant_models(include_self: true)
+ else
+ [self.class]
+ end
+
+ result = {}
+ models.each do |model|
+ query = if hierarchy_root?
+ model.where(
+ hierarchy_root_type: self.class.name,
+ hierarchy_root_id: id
+ )
+ else
+ path = public_send(:hierarchy_ancestors_path)
+ model.where(
+ 'hierarchy_ancestors_path LIKE ?',
+ "#{model.sanitize_sql_like(path)}_%"
+ )
+ end
+ if model == self.class
+ query = if include_self
+ query.or(model.where(id:))
+ else
+ query.where.not(id:)
+ end
+ end
+ result[model] = query
+ end
+ result
+ end
+ # rubocop:enable Metrics/CyclomaticComplexity
+ # rubocop:enable Metrics/PerceivedComplexity
+
# Return the attribute name that links this object to its parent.
#
# This should return the name of the attribute/relation/etc either as a
# string or symbol.
#
@@ -166,10 +416,53 @@
return nil unless source
source.respond_to?(:call) ? source.call(self) : source
end
+ # Return all of the `has_many` association names this class class has as a
+ # list of symbols.
+ #
+ # The assumption is that all of the associations we care about for
+ # getting descendants can easily be obtained directly from inspecting
+ # the class. If there are some associations that need to be manually
+ # added, one simply specify them when setting up the model.
+ #
+ # The most common case is if we want to specify additional associations.
+ # This will take all of the associations that can be auto-detected and
+ # also add in the one provided.
+ #
+ # class A
+ # include Hierarched
+ # hierarched parent_source: :parent,
+ # additional_descendant_associations: [:some_association]
+ # end
+ #
+ # There may also be a case when we want exact control over what associations
+ # that should be used. In that case, we can specify it like this:
+ #
+ # class A
+ # include Hierarched
+ # hierarched parent_source: :parent,
+ # descendant_associations: [:some_association]
+ # end
+ def hierarchy_descendant_associations
+ if hierarchable_config[:descendant_associations].present?
+ return hierarchable_config[:descendant_associations]
+ end
+
+ associations = \
+ self.class
+ .reflect_on_all_associations(:has_many)
+ .reject do |a|
+ a.name.to_s.singularize.camelcase.safe_constantize.nil?
+ end
+ .reject(&:through_reflection?)
+ .map(&:name)
+ associations += hierarchable_config[:additional_descendant_associations]
+ associations
+ end
+
# Return the string representation of the current object in the format when
# used as part of a hierarchy.
#
# If this is a new record (i.e. not saved yet), this will return "", and
# will return the string representation of the format once it is saved.
@@ -197,11 +490,10 @@
to_hierarchy_ancestors_path_format
end
end
# Return hierarchy path for given list of objects
-
def hierarchy_path_for(objects)
return '' if objects.blank?
objects.map do |obj|
to_hierarchy_format(obj)
@@ -237,92 +529,9 @@
ancestor_class = ancestor_class_name.safe_constantize
path << ancestor_class
path << ancestor_class.find(ancestor_id)
end
path
- end
-
- # Get ancestors of the same type for an object.
- #
- # For a given object type, return all ancestors that have the same type.
- # Note, since ancestors may be of different types, this may skip parts
- # of the hierarchy if the particular ancestor happens to be of a different
- # type.
- def ancestors
- return [] if !respond_to?(:hierarchy_ancestors_path) ||
- hierarchy_ancestors_path.blank?
-
- a = hierarchy_ancestors_path.split(
- hierarchable_config[:path_separator]
- ).map do |ancestor|
- ancestor_class, ancestor_id = ancestor.split(
- hierarchable_config[:record_separator]
- )
-
- if ancestor_class == self.class.name
- ancestor_class.safe_constantize.find(ancestor_id)
- end
- end
- a.compact
- end
-
- # Return the list of all ancestor objects for the current object
- #
- # Using the `hierarchy_ancestors_path`, this will iteratively get all
- # ancestor objects and return them as a list.
- #
- # As there may be ancestors of different types, this is not a single query
- # and may return things of many different types. E.g. if we have a Project,
- # Task, and a Comment, the ancestors of a coment may be the Task and the
- # Project.
- def all_ancestors
- return [] if !respond_to?(:hierarchy_ancestors_path) ||
- hierarchy_ancestors_path.blank?
-
- hierarchy_ancestors_path.split(
- hierarchable_config[:path_separator]
- ).map do |ancestor|
- ancestor_class, ancestor_id = ancestor.split(
- hierarchable_config[:record_separator]
- )
- ancestor_class.safe_constantize.find(ancestor_id)
- end
- end
-
- # Get siblings of the same type for an object.
- #
- # For a given object type, return all siblings. Note, this DOES NOT return
- # siblings of different types and those need to be queried separetly.
- # equivalent to c.hierarchy_parent.children
- #
- # Params:
- # +include_self+:: Whether or not to include self in the list.
- # Default is true
- def siblings(include_self: true)
- # The method should always return relation, not an Array sometimes and
- # Relation the other
- return self.class.none unless respond_to?(:hierarchy_parent_id)
-
- query = self.class.where(
- hierarchy_parent_type: public_send(:hierarchy_parent_type),
- hierarchy_parent_id: public_send(:hierarchy_parent_id)
- )
- query = query.where.not(id:) unless include_self
- query
- end
-
- # Get all siblings of this object regardless of object type.
- #
- # This has yet to be implemented and would likely require a separate join
- # table that has all of the data across all tables linked to the particular
- # parent. I.e. a simple table that has parent, child in it that we could
- # use to query.
- #
- # Params:
- # +include_self+:: Whether or not to include self in the list.
- # Default is true
- def all_siblings
- raise NotImplementedError
end
protected
# Set the hierarchy_parent of the current object.