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:
- Initial Tree structure root node (required)
- Comparison classes or functions (optional)
- Block to convert each matching node (optional)
And the output is defined as:
- 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.
To install through Rubygems:
gem install install tree_branch
You can also add this to your Gemfile:
bundle add tree_branch
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:
- No file open (user has no file currently editing): NONE
- Passive file open: PASSIVE
- Active file open: ACTIVE
The user is allowed only access to specific menu items depending on their state:
- NONE: open
- PASSIVE: open, save, close, print
- 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):
{
data: { name: 'Menu' },
children: [
{
data: { name: 'File' },
children: [
{ data: { name: 'Open', command: :open } }
]
},
{
data: { name: 'Edit' }
}
]
}
{
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' }
}
]
}
{
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 } }
]
}
]
}
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 }
)
{
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' }
}
]
}
{
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.
There are two ways to create comparators:
- Subclass ::TreeBranch::Comparator and implement the
valid?
method to return true/false. - 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 }
)
After a node has been compared and is deemed to be valid, it will either return one of two things:
- A
TreeBranch::Node
instance. - 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'
}
]
}
Basic steps to take to get this repository compiling:
- Install Ruby (check tree_branch.gemspec for versions supported)
- Install bundler (
gem install bundler
) - Clone the repository (
git clone git@github.com:bluemarblepayroll/tree_branch.git
) - Navigate to the root folder (
cd tree_branch
) - Install dependencies (
bundle
)
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
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:
- Merge Pull Request into master
- Update
lib/tree_branch/version.rb
using semantic versioning - Install dependencies:
bundle
- Update
CHANGELOG.md
with release notes - Commit & push master to remote and ensure CI builds master successfully
- 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.
This project is MIT Licensed.