Module: Hierarchable::InstanceMethods

Defined in:
lib/hierarchable/hierarchable.rb

Overview

Instance methods to include

Instance Method Summary collapse

Instance Method Details

#hierarchy_ancestor_models(include_self: false) ⇒ Object

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.



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/hierarchable/hierarchable.rb', line 172

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

#hierarchy_ancestors(include_self: false, models: :all) ⇒ Object

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



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/hierarchable/hierarchable.rb', line 201

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

#hierarchy_children(include_self: false, models: :all, compact: false) ⇒ Object

Get the children of 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])

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, it is unlikely that ‘include_self` would be set to `true` rubocop:disable Metrics/AbcSize rubocop:disable Metrics/CyclomaticComplexity rubocop:disable Metrics/PerceivedComplexity



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
# File 'lib/hierarchable/hierarchable.rb', line 271

def hierarchy_children(include_self: false, models: :all, compact: false)
  return {} unless respond_to?(:hierarchy_parent_id)

  # Convert all of the models to actual classes if they are passed as
  # stings.
  if models.is_a?(Array)
    models = models.map do |model|
      model.is_a?(String) ? model.safe_constantize : model
    end
  end

  result = {}
  hierarchy_descendant_associations.each do |association|
    model = class_for_association(association)

    next unless models == :all ||
                (models.is_a?(Array) && models.include?(model)) ||
                (models == :this && instance_of?(model))

    result[model.to_s] = public_send(association)
  end

  # If we want to include self, we need to do some extra work
  if include_self
    if result.key?(self.class.to_s)
      result[self.class.to_s] =
        result[self.class.to_s].or(self.class.where(id:))
    elsif models == :all ||
          models == :this ||
          (models.is_a?(Array) && models.include?(self.class))
      result[self.class.to_s] = [self]
    end
  end

  # Compact the results if necessary# Compact the results if necessary
  _, result = result.first if result.size == 1 && compact
  result
end

#hierarchy_children_models(include_self: false) ⇒ Object

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`



236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/hierarchable/hierarchable.rb', line 236

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|
    class_for_association(association)
  end

  models << self.class if include_self
  models.uniq
end

#hierarchy_descendant_associationsObject

Return all of the ‘has_many` association names this class class has as a list of symbols.

In order to be safe and not return potential duplicate associations, the only associations that are automatically detected are the ones that are the pluralized form of the model name. For example, if a model as the association ‘has_many :tasks`, there will need to be a Task model for this association to be kept.

If there are some associations that need to be manually added, one simply needs to 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 Hierarchable
  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 Hierarchable
  hierarched parent_source: :parent,
             descendant_associations: [:some_association]
end


531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
# File 'lib/hierarchable/hierarchable.rb', line 531

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.uniq
end

#hierarchy_descendant_models(include_self: false) ⇒ Object

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



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
# File 'lib/hierarchable/hierarchable.rb', line 387

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 unless klass
    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

#hierarchy_descendants(include_self: false, models: :all, compact: false) ⇒ Object

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/AbcSize rubocop:disable Metrics/CyclomaticComplexity rubocop:disable Metrics/PerceivedComplexity



432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/hierarchable/hierarchable.rb', line 432

def hierarchy_descendants(include_self: false, models: :all, compact: false)
  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|
    model = model.safe_constantize if model.is_a?(String)
    query = if hierarchy_root?
              # If it's the root, we need to base the query based on the
              # hierarchy_root attribute since the ancestor_path will be
              # empty for a root node. See the README for the explanation
              # as to why the root node has values set to nil and the
              # path as the empty string.
              model.where(
                hierarchy_root_type: self.class.name,
                hierarchy_root_id: id
              )
            else
              path = public_send(:hierarchy_full_path)
              model.where(
                'hierarchy_ancestors_path LIKE ?',
                "#{model.sanitize_sql_like(path)}%"
              )
            end

    # Make sure to include/exlude the current object depending on what the
    # user wants
    if model == self.class
      query = if include_self
                query.or(model.where(id:))
              else
                query.where.not(id:)
              end
    end
    result[model.to_s] = query
  end

  # Compact the results if necessary
  _, result = result.first if result.size == 1 && compact
  result
end

#hierarchy_full_pathObject

Return the full hierarchy path from the root to this object.

Unlike the hierarchy_ancestors_path which DOES NOT include the current object in the path, this path contains both the ancestors path AND the current object.



564
565
566
567
568
569
570
571
572
573
574
575
576
# File 'lib/hierarchable/hierarchable.rb', line 564

def hierarchy_full_path
  return '' if new_record? ||
               !respond_to?(:hierarchy_ancestors_path)

  if hierarchy_ancestors_path.present?
    format('%<path>s%<sep>s%<current>s',
           path: hierarchy_ancestors_path,
           sep: hierarchable_config[:path_separator],
           current: to_hierarchy_ancestors_path_format)
  else
    to_hierarchy_ancestors_path_format
  end
end

#hierarchy_full_path_reifiedObject

Return the full hierarchy path from the root to this object as objects.

Unlike the hierarchy_full_path that returns a string of the path, this returns a list of items. The pattern of the returned list will be

[Class, Object, Class, Object, ...]

Where the Class is the class of the object coming right after it. This representation is useful when creating a breadcrumb and we want to have both all the ancestors (like in the ancestors method), but also the collections (classes), so that we can build up a nice path with links.



603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'lib/hierarchable/hierarchable.rb', line 603

def hierarchy_full_path_reified
  return '' if new_record? ||
               !respond_to?(:hierarchy_ancestors_path)

  path = []
  hierarchy_full_path.split(hierarchable_config[:path_separator])
                     .each do |record|
    ancestor_class_name, ancestor_id = record.split(
      hierarchable_config[:record_separator]
    )
    ancestor_class = ancestor_class_name.safe_constantize
    path << ancestor_class
    path << ancestor_class.find(ancestor_id)
  end
  path
end

#hierarchy_parent(raw: false) ⇒ Object



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/hierarchable/hierarchable.rb', line 142

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
  # the `hierarchy_parent` attribute in the model will be set and so we
  # can use the `belongs_to` relationship to get the parent. However,
  # if the parent has changed or the object has yet to be saved, we can't
  # use the relationship to get the parent as the value will not have been
  # set properly yet in the model (since it's a `before_save` hook).
  use_relationship = if persisted?
                       !hierarchy_parent_changed?
                     else
                       false
                     end

  if use_relationship
    hierarchy_parent_relationship
  else
    source = hierarchy_parent_source
    source.nil? ? nil : send(source)
  end
end

#hierarchy_parent_sourceObject

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.

For example, if this is a Task, then the hierarchy_parent_source is likely the attribute that references the Project this task belongs to. If the method returns nil (the default behavior), the assumption is that this object is the root of the hierarchy.



494
495
496
497
498
499
# File 'lib/hierarchable/hierarchable.rb', line 494

def hierarchy_parent_source
  source = hierarchable_config[:parent_source]
  return nil unless source

  source.respond_to?(:call) ? source.call(self) : source
end

#hierarchy_path_for(objects) ⇒ Object

Return hierarchy path for given list of objects



579
580
581
582
583
584
585
# File 'lib/hierarchable/hierarchable.rb', line 579

def hierarchy_path_for(objects)
  return '' if objects.blank?

  objects.map do |obj|
    to_hierarchy_format(obj)
  end.join(hierarchable_config[:path_separator])
end

#hierarchy_root?Boolean

Returns:

  • (Boolean)


138
139
140
# File 'lib/hierarchable/hierarchable.rb', line 138

def hierarchy_root?
  hierarchy_root.nil?
end

#hierarchy_sibling_models(include_self: false) ⇒ Object

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.



319
320
321
322
323
324
325
326
# File 'lib/hierarchable/hierarchable.rb', line 319

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

#hierarchy_siblings(include_self: false, models: :all, compact: false) ⇒ Object

Get siblings of an 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



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
# File 'lib/hierarchable/hierarchable.rb', line 338

def hierarchy_siblings(include_self: false, models: :all, compact: false)
  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|
    model = model.safe_constantize if model.is_a?(String)
    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.to_s] = query
  end

  # Compact the results if necessary
  _, result = result.first if result.size == 1 && compact
  result
end

#to_hierarchy_ancestors_path_formatObject

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.



553
554
555
556
557
# File 'lib/hierarchable/hierarchable.rb', line 553

def to_hierarchy_ancestors_path_format
  return '' if new_record?

  to_hierarchy_format(self)
end

#to_hierarchy_format(object) ⇒ Object



587
588
589
# File 'lib/hierarchable/hierarchable.rb', line 587

def to_hierarchy_format(object)
  "#{object.class}#{hierarchable_config[:record_separator]}#{object.id}"
end