TreeBranch

Gem Version Build Status Maintainability Test Coverage License: MIT

This library allows you to traverse an entire tree structure, compare all nodes, and choose a tree structure to return. The basic input is defined as:

  1. Initial Tree structure root node (required)
  2. Comparison classes or functions (optional)
  3. Block to convert each matching node (optional)

And the output is defined as:

  1. Compared and/or converted tree structure (root node)

The specific use-case this was designed for was a dynamic web application menu. In this specific example, we wanted either a static file or a database to store and define all possible menus. Then we wanted to input a request's lifecycle context (user, URL, parameters, authorization, etc.) and return the menu that matched the current spot in the application.

Installation

To install through Rubygems:

gem install install tree_branch

You can also add this to your Gemfile:

bundle add tree_branch

Examples

Word Processor Application Menu Example

Take the following application menu structure:

menu = {
  data: { name: 'Menu' },
  children: [
    {
      data: { name: 'File' },
      children: [
        { data: { name: 'Open', command: :open } },
        { data: { name: 'Save', command: :save, right: :write } },
        { data: { name: 'Close', command: :close } },
        {
          data: { name: 'Print', command: :print },
          children: [
            { data: { name: 'Print' } },
            { data: { name: 'Print Preview' } },
          ]
        },
      ]
    },
    {
      data: { name: 'Edit' },
      children: [
        { data: { name: 'Cut', command: :cut } },
        { data: { name: 'Copy', command: :copy } },
        { data: { name: 'Paste', command: :paste } }
      ]
    }
  ]
}.freeze

There are three application states:

  1. No file open (user has no file currently editing): NONE
  2. Passive file open: PASSIVE
  3. Active file open: ACTIVE

The user is allowed only access to specific menu items depending on their state:

  1. NONE: open
  2. PASSIVE: open, save, close, print
  3. ACTIVE: open, save, close, print, cut, copy, paste

We can implement this as a comparator class:

class StateComparator < ::TreeBranch::Comparator
  STATE_OPS = {
    none: %i[open],
    passive: %i[open save close print],
    active: %i[open save close print cut copy paste]
  }.freeze
  private_constant :STATE_OPS

  def valid?
    data.command.nil? || Array(STATE_OPS[context[:state]]).include?(data.command)
  end
end

Finally, we can process this for all three states:

no_file_menu =
  TreeBranch.process(
    node: menu,
    comparators: StateComparator,
    context: { state: :none }
  )

passive_file_menu =
  TreeBranch.process(
    node: menu,
    comparators: StateComparator,
    context: { state: :passive }
  )

active_file_menu =
  TreeBranch.process(
    node: menu,
    comparators: StateComparator,
    context: { state: :active }
  )

We would get the following structure back (in the form of a root Node object but expressed as a hash below):

No File Menu Result
{
  data: { name: 'Menu' },
  children: [
    {
      data: { name: 'File' },
      children: [
        { data: { name: 'Open', command: :open } }
      ]
    },
    {
      data: { name: 'Edit' }
    }
  ]
}
Passive File Menu Result
{
  data: { name: 'Menu' },
  children: [
    {
      data: { name: 'File' },
      children: [
        { data: { name: 'Open', command: :open } },
        { data: { name: 'Save', command: :save, right: :write } },
        { data: { name: 'Close', command: :close } },
        {
          data: { name: 'Print', command: :print },
          children: [
            { data: { name: 'Print' } },
            { data: { name: 'Print Preview' } }
          ]
        }
      ]
    },
    {
      data: { name: 'Edit' }
    }
  ]
}
Active File Menu Result
{
  data: { name: 'Menu' },
  children: [
    {
      data: { name: 'File' },
      children: [
        { data: { name: 'Open', command: :open } },
        { data: { name: 'Save', command: :save, right: :write } },
        { data: { name: 'Close', command: :close } },
        {
          data: { name: 'Print', command: :print },
          children: [
            { data: { name: 'Print' } },
            { data: { name: 'Print Preview' } },
          ]
        },
      ]
    },
    {
      data: { name: 'Edit' },
      children: [
        { data: { name: 'Cut', command: :cut } },
        { data: { name: 'Copy', command: :copy } },
        { data: { name: 'Paste', command: :paste } }
      ]
    }
  ]
}

Stacking Comparators

You can also choose to input multiple comparators (technically 0 to N). For example, let's stack authorization into our application menu example using this comparator:

class AuthorizationComparator < ::TreeBranch::Comparator
  def valid?
    data.right.nil? || Array(context.rights).include?(data.right)
  end
end

Now, we can pass in our current user's rights and use them when appropriate:

passive_read_only_menu =
  ::TreeBranch.process(
    node: menu,
    comparators: [StateComparator, AuthorizationComparator],
    context: { state: :passive }
  )

passive_read_write_menu =
  ::TreeBranch.process(
    node: menu,
    comparators: [StateComparator, AuthorizationComparator],
    context: { state: :passive, rights: :write }
  )
Read-Only User Passively Editing Result
{
  data: { name: 'Menu' },
  children: [
    {
      data: { name: 'File' },
      children: [
        { data: { name: 'Open', command: :open } },
        { data: { name: 'Close', command: :close } },
        {
          data: { name: 'Print', command: :print },
          children: [
            { data: { name: 'Print' } },
            { data: { name: 'Print Preview' } }
          ]
        }
      ]
    },
    {
      data: { name: 'Edit' }
    }
  ]
}
Read/Write User Passively Editing Result
{
  data: { name: 'Menu' },
  children: [
    {
      data: { name: 'File' },
      children: [
        { data: { name: 'Open', command: :open } },
        { data: { name: 'Save', command: :save, right: :write } },
        { data: { name: 'Close', command: :close } },
        {
          data: { name: 'Print', command: :print },
          children: [
            { data: { name: 'Print' } },
            { data: { name: 'Print Preview' } }
          ]
        }
      ]
    },
    {
      data: { name: 'Edit' }
    }
  ]
}

Notice now our read-only menu is missing the 'save' item.

Comparator Creation

There are two ways to create comparators:

  1. Subclass ::TreeBranch::Comparator and implement the valid? method to return true/false.
  2. Create lambda/proc that accepts two arguments: data and context and returns true/false.

Option one is shown in the above example, while option two can be illustrated as:

auth_comparator = lambda do |data, context|
  data.right.nil? || Array(context.rights).include?(data.right)
end

passive_read_only_menu =
  TreeBranch.process(
    node: menu,
    comparators: [StateComparator, auth_comparator],
    context: { state: :passive }
  )

Node Post-Processing / Conversion

After a node has been compared and is deemed to be valid, it will either return one of two things:

  1. A TreeBranch::Node instance.
  2. The return value of the block passed into the process method. Note: If the block returns nil then it will be ignored as if it was invalid.

In our above example, we did not pass in a block so they would all return Node instances. The passed in block is your chance to return instances of another class, or even do some other post-processing routines. For example, lets return an instance of a new type: MenuItem as shown below:

class MenuItem
  acts_as_hashable # Provided by https://github.com/bluemarblepayroll/acts_as_hashable

  attr_reader :menu_items, :name

  def initialize(name: '', menu_items: [])
    @name       = name
    @menu_items = self.class.array(menu_items)
  end

  def eql?(other)
    name == other.name && menu_items == other.menu_items
  end

  alias == eql?
end

We can now convert this in the block:

passive_read_write_menu =
  TreeBranch.process(
    node: menu,
    comparators: [StateComparator, auth_comparator],
    context: { state: :passive, rights: :write }
  ) { |data, children, context| MenuItem.new(data.name, children) }

Our resulting data set (visualized as a hash):

{
  name: 'Menu',
  menu_items: [
    {
      name: 'File',
      menu_items: [
        { name: 'Open' },
        { name: 'Save' },
        { name: 'Close' },
        {
          name: 'Print',
          menu_items: [
            { name: 'Print' },
            { name: 'Print Preview' }
          ]
        }
      ]
    },
    {
      name: 'Edit'
    }
  ]
}

Contributing

Development Environment Configuration

Basic steps to take to get this repository compiling:

  1. Install Ruby (check tree_branch.gemspec for versions supported)
  2. Install bundler (gem install bundler)
  3. Clone the repository (git clone [email protected]:bluemarblepayroll/tree_branch.git)
  4. Navigate to the root folder (cd tree_branch)
  5. Install dependencies (bundle)

Running Tests

To execute the test suite run:

bundle exec rspec spec --format documentation

Alternatively, you can have Guard watch for changes:

bundle exec guard

Also, do not forget to run Rubocop:

bundle exec rubocop

Note that the default Rake tasks runs both test and Rubocop:

bundle exec rake

Publishing

Note: ensure you have proper authorization before trying to publish new versions.

After code changes have successfully gone through the Pull Request review process then the following steps should be followed for publishing new versions:

  1. Merge Pull Request into master
  2. Update lib/tree_branch/version.rb using semantic versioning
  3. Install dependencies: bundle
  4. Update CHANGELOG.md with release notes
  5. Commit & push master to remote and ensure CI builds master successfully
  6. Run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Note: ensure you have proper authorization before trying to publish new versions.

License

This project is MIT Licensed.