From baa001ee02a988d8063bf6786166b97261294990 Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sat, 3 Mar 2012 22:22:52 -0300 Subject: [PATCH 01/10] Add Router::Tree and Router::RouteNode --- lib/harbor/router/route_node.rb | 12 ++++++++ lib/harbor/router/tree.rb | 26 +++++++++++++++++ test/router/tree_test.rb | 52 +++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 lib/harbor/router/route_node.rb create mode 100644 lib/harbor/router/tree.rb create mode 100644 test/router/tree_test.rb diff --git a/lib/harbor/router/route_node.rb b/lib/harbor/router/route_node.rb new file mode 100644 index 0000000..98a57e5 --- /dev/null +++ b/lib/harbor/router/route_node.rb @@ -0,0 +1,12 @@ +module Harbor + class Router + class RouteNode + attr_reader :action, :fragment + + def initialize(action, tokens = []) + @action = action + @fragment = tokens.shift + end + end + end +end diff --git a/lib/harbor/router/tree.rb b/lib/harbor/router/tree.rb new file mode 100644 index 0000000..6c706a3 --- /dev/null +++ b/lib/harbor/router/tree.rb @@ -0,0 +1,26 @@ +module Harbor + class Router + class Tree + attr_reader :root, :home + + def insert(tokens, action) + if tokens.empty? + @home = RouteNode.new(action) + elsif @root + @root.insert(action, tokens) + else + @root = RouteNode.new(action, tokens) + end + self + end + + def search(tokens) + if tokens.empty? + home + else + root.search(tokens) + end + end + end + end +end diff --git a/test/router/tree_test.rb b/test/router/tree_test.rb new file mode 100644 index 0000000..cb4bc81 --- /dev/null +++ b/test/router/tree_test.rb @@ -0,0 +1,52 @@ +require "minitest/autorun" +require_relative '../../lib/harbor/router/tree' +require_relative '../../lib/harbor/router/route_node' + +module Router + class TreeTest < MiniTest::Unit::TestCase + def setup + @tree = Harbor::Router::Tree.new + end + + def test_creates_home_node_if_tokens_are_empty + @tree.insert([], :home) + assert_equal :home, @tree.home.action + end + + def test_replaces_home_node_with_new_node + @tree.insert([], :home).insert([], :new_home) + assert_equal :new_home, @tree.home.action + end + + def test_creates_root_node_for_single_token + @tree.insert(['posts'], :action) + assert_equal :action, @tree.root.action + assert_equal 'posts', @tree.root.fragment + end + + def test_delegates_insertion_to_root_node + mock = MiniTest::Mock.new + mock.expect :insert, nil, [:action, ['posts']] + @tree.instance_variable_set(:@root, mock) + + @tree.insert(['posts'], :action) + + assert mock.verify + end + + def test_delegates_search_to_root_node + mock = MiniTest::Mock.new + mock.expect :search, nil, [['posts']] + @tree.instance_variable_set(:@root, mock) + + @tree.search(['posts']) + + assert mock.verify + end + + def test_matches_home_route_if_registered + @tree.insert([], :home) + assert_equal :home, @tree.search([]).action + end + end +end From b594b12956ee60b6c396626c3a739dfa43b2b7ea Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 03:43:34 -0300 Subject: [PATCH 02/10] Implement Router::RouteNode insertion (it already handles a few edge cases) --- lib/harbor/router/route_node.rb | 72 +++++++++++++++++++++++++-- lib/harbor/router/tree.rb | 10 +--- test/router/route_node_test.rb | 88 +++++++++++++++++++++++++++++++++ test/router/tree_test.rb | 6 --- 4 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 test/router/route_node_test.rb diff --git a/lib/harbor/router/route_node.rb b/lib/harbor/router/route_node.rb index 98a57e5..d048b88 100644 --- a/lib/harbor/router/route_node.rb +++ b/lib/harbor/router/route_node.rb @@ -1,11 +1,75 @@ module Harbor class Router class RouteNode - attr_reader :action, :fragment + MATCH = 0 + RIGHT = 1 + LEFT = -1 + WILDCARD_FRAGMENT = '*' + WILDCARD_CHAR = ?: - def initialize(action, tokens = []) - @action = action - @fragment = tokens.shift + attr_reader :fragment, :tokens + attr_accessor :action, :left, :right, :match + + # Inserts or updates tree nodes + # + # @return [ RouteNode ] The inserted node + def insert(action, tokens) + (leaf = find_or_create_node!(tokens)).action = action + leaf + end + + # Finds or create nodes for provided tokens, if a node is not found for a + # token, a "blank" node will be created and the search will continue. + # + # @return [ RouteNode ] The node for a set of tokens + def find_or_create_node!(tokens, index = 0) + part = tokens[index] + last_token = index == tokens.size - 1 + + # Non wildcard routes should take precedence + if wildcard? && part[0] != WILDCARD_CHAR + replace!(tokens, index) + end + + if @fragment.nil? + @fragment = fragment_from_token(part) + # Removes "extra" tokens + @tokens = tokens[0..index] + end + + # Ensures "virtual" wildcard nodes have the right tokens set so + # that we can map parameters back to route handlers + @tokens = tokens[0..index] if wildcard? && last_token + + direction = wildcard?? MATCH : part <=> @fragment + + # If it is a match and there are no more fragments to consume + return self if last_token && direction == MATCH + + case direction + when MATCH + (@match ||= RouteNode.new).find_or_create_node!(tokens, index + 1) + when LEFT + (@left ||= RouteNode.new).find_or_create_node!(tokens, index) + when RIGHT + (@right ||= RouteNode.new).find_or_create_node!(tokens, index) + end + end + + def wildcard? + @fragment == WILDCARD_FRAGMENT + end + + def fragment_from_token(token) + (token[0] == WILDCARD_CHAR) ? WILDCARD_FRAGMENT : token + end + + def replace!(tokens, index) + @left = RouteNode.new.insert(@action, @tokens) + @right = nil + @action = nil + @tokens = nil + @fragment = nil end end end diff --git a/lib/harbor/router/tree.rb b/lib/harbor/router/tree.rb index 6c706a3..28f1b6c 100644 --- a/lib/harbor/router/tree.rb +++ b/lib/harbor/router/tree.rb @@ -6,20 +6,14 @@ class Tree def insert(tokens, action) if tokens.empty? @home = RouteNode.new(action) - elsif @root - @root.insert(action, tokens) else - @root = RouteNode.new(action, tokens) + (@root ||= RouteNode.new).insert(action, tokens) end self end def search(tokens) - if tokens.empty? - home - else - root.search(tokens) - end + tokens.empty?? home : root.search(tokens) end end end diff --git a/test/router/route_node_test.rb b/test/router/route_node_test.rb new file mode 100644 index 0000000..d1b92b8 --- /dev/null +++ b/test/router/route_node_test.rb @@ -0,0 +1,88 @@ +require "minitest/autorun" +require_relative '../../lib/harbor/router/route_node' + +module Router + class RouteNodeTest < MiniTest::Unit::TestCase + def setup + @node = Harbor::Router::RouteNode.new + @node.insert(:index, ['posts']) + end + + def test_assigns_values_if_blank_inserting_a_single_token + assert_equal 'posts', @node.fragment + assert_equal :index, @node.action + assert_equal ['posts'], @node.tokens + end + + def test_inserts_node_on_the_left + @node.insert(:action, ['categories']) + assert_equal 'categories', @node.left.fragment + assert_equal :action, @node.left.action + assert_equal ['categories'], @node.left.tokens + end + + def test_inserts_node_on_the_right + @node.insert(:action, ['tags']) + assert_equal 'tags', @node.right.fragment + assert_equal :action, @node.right.action + assert_equal ['tags'], @node.right.tokens + end + + def test_creates_match_node + @node.insert(:action, [@node.fragment, 'id']) + assert_equal 'id', @node.match.fragment + assert_equal :action, @node.match.action + assert_equal ['posts', 'id'], @node.match.tokens + end + + def test_creates_required_virtual_nodes + @node.insert(:action, ['categories', 'id']) + assert_equal 'categories', @node.left.fragment + assert_equal nil, @node.left.action + assert_equal ['categories'], @node.left.tokens + end + + def test_updates_virtual_nodes_with_new_action + @node.insert(:id_action, ['categories', 'id']) + @node.insert(:categories_action, ['categories']) + assert_equal :categories_action, @node.left.action + end + + def test_identifies_wildcard_tokens + wild_node = @node.insert(:wild_action, ['posts', ':id']) + assert wild_node.wildcard? + end + + def test_handles_multiple_wildcard_tokens_under_match_node + id = @node.insert(:id, ['posts', ':id']) + comments = @node.insert(:comments, ['posts', ':post_id', 'comments']) + + assert_equal id, @node.match + assert_equal comments, @node.match.match + end + + def test_updates_wildcard_virtual_nodes_with_action_and_tokens + @node.insert(:comments, ['posts', ':post_id', 'comments']) + @node.insert(:id, ['posts', ':id']) + + assert_equal :id, @node.match.action + assert_equal ['posts', ':id'], @node.match.tokens + end + + def test_non_wildcard_routes_take_precedence_over_wildcard_ones + @node.insert(:id, ['posts', ':id']) + @node.insert(:recent, ['posts', 'recent']) + + assert_equal :recent, @node.match.action + assert_equal :id, @node.match.left.action + end + + def test_handles_nested_wildcard_tokens + id_node = @node.insert(:id, ['posts', ':id']) + comment_node = @node.insert(:comment, ['posts', ':post_id', ':comment_id']) + + assert_equal id_node, @node.match + assert_equal comment_node, @node.match.match + end + end +end diff --git a/test/router/tree_test.rb b/test/router/tree_test.rb index cb4bc81..303c603 100644 --- a/test/router/tree_test.rb +++ b/test/router/tree_test.rb @@ -18,12 +18,6 @@ def test_replaces_home_node_with_new_node assert_equal :new_home, @tree.home.action end - def test_creates_root_node_for_single_token - @tree.insert(['posts'], :action) - assert_equal :action, @tree.root.action - assert_equal 'posts', @tree.root.fragment - end - def test_delegates_insertion_to_root_node mock = MiniTest::Mock.new mock.expect :insert, nil, [:action, ['posts']] From 8ddf285ab738bc73b7c1ab549e2444373c5c4625 Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 18:47:14 -0300 Subject: [PATCH 03/10] Implement search for Router::RouteNode, add some more documentation and prepare for complex wildcard matching --- lib/harbor/router/route_node.rb | 46 ++++++++++++++------ lib/harbor/router/wildcard_route_node.rb | 18 ++++++++ test/router/route_node_test.rb | 55 ++++++++++++++++++++---- 3 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 lib/harbor/router/wildcard_route_node.rb diff --git a/lib/harbor/router/route_node.rb b/lib/harbor/router/route_node.rb index d048b88..327c786 100644 --- a/lib/harbor/router/route_node.rb +++ b/lib/harbor/router/route_node.rb @@ -1,5 +1,7 @@ module Harbor class Router + # A Ternary Search tree implementation that can be extended to a n-way search + # tree at insertion time. It also uses the AVL algorithm for self balancing (TODO) class RouteNode MATCH = 0 RIGHT = 1 @@ -10,6 +12,22 @@ class RouteNode attr_reader :fragment, :tokens attr_accessor :action, :left, :right, :match + def initialize(action = nil) + @action = action + end + + def search(tokens, current_token = nil) + current_token = tokens.shift unless current_token + + if current_token == @fragment || wildcard? + return self if tokens.empty? + return @match.search(tokens) if @match + end + + return @left.search(tokens, current_token) if @left && current_token < @fragment + return @right.search(tokens, current_token) if @right + end + # Inserts or updates tree nodes # # @return [ RouteNode ] The inserted node @@ -24,12 +42,10 @@ def insert(action, tokens) # @return [ RouteNode ] The node for a set of tokens def find_or_create_node!(tokens, index = 0) part = tokens[index] - last_token = index == tokens.size - 1 - # Non wildcard routes should take precedence - if wildcard? && part[0] != WILDCARD_CHAR - replace!(tokens, index) - end + # This will extend the current node with "complex wildcard behavior" / + # n-way search tree + return replace!(tokens, index) if should_replace?(part) if @fragment.nil? @fragment = fragment_from_token(part) @@ -37,14 +53,17 @@ def find_or_create_node!(tokens, index = 0) @tokens = tokens[0..index] end + is_last_token = index == tokens.size - 1 + # Ensures "virtual" wildcard nodes have the right tokens set so # that we can map parameters back to route handlers - @tokens = tokens[0..index] if wildcard? && last_token + @tokens = tokens[0..index] if wildcard? && is_last_token + # Wildcard routes should always be considered matches direction = wildcard?? MATCH : part <=> @fragment # If it is a match and there are no more fragments to consume - return self if last_token && direction == MATCH + return self if is_last_token && direction == MATCH case direction when MATCH @@ -64,12 +83,15 @@ def fragment_from_token(token) (token[0] == WILDCARD_CHAR) ? WILDCARD_FRAGMENT : token end + def should_replace?(part) + # On a wildcard node with an incoming "non-wildcard" node + (wildcard? && part[0] != WILDCARD_CHAR) || + # ... or on a "non-wildcard" node with an incoming wildcard node + !@fragment.nil? && !wildcard? && part[0] == WILDCARD_CHAR + end + def replace!(tokens, index) - @left = RouteNode.new.insert(@action, @tokens) - @right = nil - @action = nil - @tokens = nil - @fragment = nil + self.extend WildcardRouteNode end end end diff --git a/lib/harbor/router/wildcard_route_node.rb b/lib/harbor/router/wildcard_route_node.rb new file mode 100644 index 0000000..c6061c0 --- /dev/null +++ b/lib/harbor/router/wildcard_route_node.rb @@ -0,0 +1,18 @@ +module Harbor + class Router + # TODO: Add inspect information to distinguish from "normal" routes + module WildcardRouteNode + def search + raise 'Not working yet' + end + + def insert(action, tokens) + raise 'Not working yet' + end + + def trees + @trees ||= {} + end + end + end +end diff --git a/test/router/route_node_test.rb b/test/router/route_node_test.rb index d1b92b8..f063505 100644 --- a/test/router/route_node_test.rb +++ b/test/router/route_node_test.rb @@ -1,5 +1,6 @@ require "minitest/autorun" require_relative '../../lib/harbor/router/route_node' +require_relative '../../lib/harbor/router/wildcard_route_node' module Router class RouteNodeTest < MiniTest::Unit::TestCase @@ -69,14 +70,6 @@ def test_updates_wildcard_virtual_nodes_with_action_and_tokens assert_equal ['posts', ':id'], @node.match.tokens end - def test_non_wildcard_routes_take_precedence_over_wildcard_ones - @node.insert(:id, ['posts', ':id']) - @node.insert(:recent, ['posts', 'recent']) - - assert_equal :recent, @node.match.action - assert_equal :id, @node.match.left.action - end - def test_handles_nested_wildcard_tokens id_node = @node.insert(:id, ['posts', ':id']) comment_node = @node.insert(:comment, ['posts', ':post_id', ':comment_id']) @@ -84,5 +77,51 @@ def test_handles_nested_wildcard_tokens assert_equal id_node, @node.match assert_equal comment_node, @node.match.match end + + def test_handles_non_wildcard_routes_precedence_by_extending_existing_wildcard_route + @node.insert(:id, ['posts', ':id']) + @node.insert(:recent, ['posts', 'recent']) + + assert_kind_of Harbor::Router::WildcardRouteNode, @node.match + refute_nil @node.match.trees + end + + def test_handles_non_wildcard_routes_precedence_by_extending_existing_simple_route + @node.insert(:recent, ['posts', 'recent']) + @node.insert(:id, ['posts', ':id']) + + assert_kind_of Harbor::Router::WildcardRouteNode, @node.match + refute_nil @node.match.trees + end + + def test_finds_node_on_the_right + @node.insert(:recent, ['tags']) + @node.insert(:videos, ['videos']) + + assert_equal :videos, @node.search(['videos']).action + end + + def test_finds_node_on_the_left + @node.insert(:categories, ['categories']) + @node.insert(:authors, ['authors']) + + assert_equal :authors, @node.search(['authors']).action + end + + def test_finds_matching_nodes + @node.insert(:categories, ['posts', 'categories']) + + assert_equal :categories, @node.search(['posts', 'categories']).action + end + + def test_finds_wildcard_matching_nodes + @node.insert(:show, ['posts', ':id']) + + assert_equal :show, @node.search(['posts', '1234']).action + end + + def test_return_nil_if_cant_be_found + assert_nil @node.search(['whaaat?']) + end end end From 7b4b4b4becac3631ebf60cd0748847b3051e2581 Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 19:58:01 -0300 Subject: [PATCH 04/10] Wrap up complex wildcard matching with n-way tree --- lib/harbor/router/route_node.rb | 23 ++++++- lib/harbor/router/wildcard_route_node.rb | 36 +++++++++-- test/router/wildcard_route_node_test.rb | 80 ++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 test/router/wildcard_route_node_test.rb diff --git a/lib/harbor/router/route_node.rb b/lib/harbor/router/route_node.rb index 327c786..e2c3a11 100644 --- a/lib/harbor/router/route_node.rb +++ b/lib/harbor/router/route_node.rb @@ -16,6 +16,7 @@ def initialize(action = nil) @action = action end + # Basic ternary search tree algorithm def search(tokens, current_token = nil) current_token = tokens.shift unless current_token @@ -91,7 +92,27 @@ def should_replace?(part) end def replace!(tokens, index) - self.extend WildcardRouteNode + extend WildcardRouteNode + find_or_create_node!(tokens, index) + end + + def assign_from(other_node) + @left = other_node.left + @right = other_node.right + @action = other_node.action + @tokens = other_node.tokens + @fragment = other_node.fragment + @match = other_node.match + self + end + + def reset! + @left = nil + @right = nil + @match = nil + @action = nil + @tokens = nil + @fragment = nil end end end diff --git a/lib/harbor/router/wildcard_route_node.rb b/lib/harbor/router/wildcard_route_node.rb index c6061c0..0a8886b 100644 --- a/lib/harbor/router/wildcard_route_node.rb +++ b/lib/harbor/router/wildcard_route_node.rb @@ -1,13 +1,41 @@ module Harbor class Router + # Used to extend a "simple" node with n-way search tree behavior # TODO: Add inspect information to distinguish from "normal" routes module WildcardRouteNode - def search - raise 'Not working yet' + def self.extended(base) + new_node = RouteNode.new.assign_from(base) + if base.wildcard? + base.wildcard_tree = new_node + else + base.trees[base.fragment] = new_node + end + base.reset! end - def insert(action, tokens) - raise 'Not working yet' + attr_accessor :wildcard_tree + + def search(tokens) + part = tokens.first + + exact_result = if (tree = trees[part]) + # The dup is required as the search method will consume tokens + tree.search(tokens.dup) + end + return exact_result if exact_result + + # An exact match could be found? Lets give one last shot and try to match + # wildcard routes ;) + @wildcard_tree.search(tokens) if @wildcard_tree + end + + def find_or_create_node!(tokens, index = 0) + part = tokens[index] + if part[0] == self.class::WILDCARD_CHAR + (@wildcard_tree ||= RouteNode.new).find_or_create_node!(tokens, index) + else + (trees[part] ||= RouteNode.new).find_or_create_node!(tokens, index) + end end def trees diff --git a/test/router/wildcard_route_node_test.rb b/test/router/wildcard_route_node_test.rb new file mode 100644 index 0000000..09aacb3 --- /dev/null +++ b/test/router/wildcard_route_node_test.rb @@ -0,0 +1,80 @@ +require "minitest/autorun" +require_relative '../../lib/harbor/router/route_node' +require_relative '../../lib/harbor/router/wildcard_route_node' + +module Router + class WildcardRouteNodeTest < MiniTest::Unit::TestCase + def setup + @node = Harbor::Router::RouteNode.new + end + + def test_creates_tree_entry_for_exact_matches_when_extended + @node.insert(:exact, ['parts']) + @node.insert(:wild, [':id']) + + assert_equal :exact, @node.trees['parts'].action + end + + def test_creates_wildcard_tree_when_extended + @node.insert(:wild, [':id']) + @node.insert(:exact, ['parts']) + + assert_equal :wild, @node.wildcard_tree.action + end + + def test_delegates_insertion_to_inner_trees + @node.insert(:wild, [':id']) + @node.insert(:exact, ['parts']) + @node.insert(:inner_wild, ['parts', ':id']) + + assert_equal :inner_wild, @node.trees['parts'].match.action + end + + def test_delegates_insertion_to_wildcard_tree + @node.insert(:exact, ['parts']) + @node.insert(:wild, [':id']) + @node.insert(:wild_with_exact, [':id', 'orders']) + + assert_equal :wild_with_exact, @node.wildcard_tree.match.action + end + + def test_updates_exact_matches_upon_insertion + @node.insert(:wild, [':id']) + @node.insert(:inner_wild, ['parts', ':id']) + @node.insert(:action, ['parts']) + + assert_equal :action, @node.trees['parts'].action + assert_equal ['parts'], @node.trees['parts'].tokens + end + + def test_updates_wildcard_matches_upon_insertion + @node.insert(:exact, ['parts']) + @node.insert(:wild_with_exact, [':id', 'orders']) + @node.insert(:action, [':id']) + + assert_equal :action, @node.wildcard_tree.action + assert_equal [':id'], @node.wildcard_tree.tokens + end + + def test_exact_matches_take_precedence + @node.insert(:show, ['parts', ':id']) + @node.insert(:comments, [':id', 'comments']) + + assert_equal :show, @node.search(['parts', '1234']).action + end + + def test_finds_wildcard_match + @node.insert(:show, ['parts', ':id']) + @node.insert(:comments, [':id', 'comments']) + + assert_equal :comments, @node.search(['foo', 'comments']).action + end + + def test_retries_search_with_wildcard_matches + @node.insert(:all_exact, ['a', 'b', 'c']) + @node.insert(:all_wild, [':a', ':b', ':c']) + + assert_equal :all_wild, @node.search(['a', 'b', 'd']).action + end + end +end From e33222c44ccf547bac591f38c120b03413f3363f Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 20:05:53 -0300 Subject: [PATCH 05/10] Fix requires for the recent routing work --- lib/harbor/router.rb | 3 +++ test/router/route_node_test.rb | 4 +--- test/router/wildcard_route_node_test.rb | 4 +--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/harbor/router.rb b/lib/harbor/router.rb index 50bd351..0c5b89f 100644 --- a/lib/harbor/router.rb +++ b/lib/harbor/router.rb @@ -1,5 +1,8 @@ require "set" require_relative "router/route" +require_relative "router/tree" +require_relative "router/route_node" +require_relative "router/wildcard_route_node" module Harbor class Router diff --git a/test/router/route_node_test.rb b/test/router/route_node_test.rb index f063505..54d401c 100644 --- a/test/router/route_node_test.rb +++ b/test/router/route_node_test.rb @@ -1,6 +1,4 @@ -require "minitest/autorun" -require_relative '../../lib/harbor/router/route_node' -require_relative '../../lib/harbor/router/wildcard_route_node' +require_relative "../helper" module Router class RouteNodeTest < MiniTest::Unit::TestCase diff --git a/test/router/wildcard_route_node_test.rb b/test/router/wildcard_route_node_test.rb index 09aacb3..412eb15 100644 --- a/test/router/wildcard_route_node_test.rb +++ b/test/router/wildcard_route_node_test.rb @@ -1,6 +1,4 @@ -require "minitest/autorun" -require_relative '../../lib/harbor/router/route_node' -require_relative '../../lib/harbor/router/wildcard_route_node' +require_relative "../helper" module Router class WildcardRouteNodeTest < MiniTest::Unit::TestCase From fb6186bbcebd85fb9a3a172a7fa1593ffd4cfa58 Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 20:40:21 -0300 Subject: [PATCH 06/10] * Integrate new routing tree with router (which fixed ControllerTest#test_generated_routes_match_actions) * Replace Route with RouteNode * Rename WildcardRouteNode to WildcardRoute * Change Router::Tree#search to return action from found route * Fix require for test/router/tree_test.rb --- lib/harbor/router.rb | 19 +-- lib/harbor/router/route.rb | 158 ++++++++++-------- lib/harbor/router/route_node.rb | 119 ------------- lib/harbor/router/tree.rb | 7 +- ...ldcard_route_node.rb => wildcard_route.rb} | 8 +- .../{route_node_test.rb => route_test.rb} | 8 +- test/router/tree_test.rb | 12 +- ...te_node_test.rb => wildcard_route_test.rb} | 4 +- 8 files changed, 116 insertions(+), 219 deletions(-) delete mode 100644 lib/harbor/router/route_node.rb rename lib/harbor/router/{wildcard_route_node.rb => wildcard_route.rb} (81%) rename test/router/{route_node_test.rb => route_test.rb} (94%) rename test/router/{wildcard_route_node_test.rb => wildcard_route_test.rb} (95%) diff --git a/lib/harbor/router.rb b/lib/harbor/router.rb index 0c5b89f..cd34a50 100644 --- a/lib/harbor/router.rb +++ b/lib/harbor/router.rb @@ -1,8 +1,7 @@ require "set" -require_relative "router/route" require_relative "router/tree" -require_relative "router/route_node" -require_relative "router/wildcard_route_node" +require_relative "router/route" +require_relative "router/wildcard_route" module Harbor class Router @@ -27,13 +26,13 @@ def self.instance def clear! @methods = { - "GET" => Route.new, - "POST" => Route.new, - "PUT" => Route.new, - "DELETE" => Route.new, - "HEAD" => Route.new, - "OPTIONS" => Route.new, - "PATCH" => Route.new + "GET" => Tree.new, + "POST" => Tree.new, + "PUT" => Tree.new, + "DELETE" => Tree.new, + "HEAD" => Tree.new, + "OPTIONS" => Tree.new, + "PATCH" => Tree.new } end end diff --git a/lib/harbor/router/route.rb b/lib/harbor/router/route.rb index a7c1a30..c0f0ca0 100644 --- a/lib/harbor/router/route.rb +++ b/lib/harbor/router/route.rb @@ -1,104 +1,124 @@ module Harbor class Router + # A Ternary Search tree implementation that can be extended to a n-way search + # tree at insertion time. It also uses the AVL algorithm for self balancing (TODO) class Route - - PATH_SEPARATOR = /[\/;]/ + MATCH = 0 + RIGHT = 1 + LEFT = -1 + WILDCARD_FRAGMENT = '*' + WILDCARD_CHAR = ?: + PATH_SEPARATOR = /[\/;]/ def self.expand(path) path.split(PATH_SEPARATOR).reject { |part| part.empty? } end - attr_reader :fragment, :action, :tokens, :match, :left , :right - - def initialize(fragment = nil, action = nil) - @fragment = fragment - @action = action - @tokens = nil + attr_reader :fragment, :tokens + attr_accessor :action, :left, :right, :match - @match = nil - @left = nil - @right = nil + def initialize(action = nil) + @action = action end - def node(tokens, index = 0, length = tokens.length - 1) - part = tokens[index] + # Basic ternary search tree algorithm + def search(tokens, current_token = nil) + current_token = tokens.shift unless current_token - if part == @fragment || @fragment[0] == ?: then - return self if index == length - return @match.node(tokens, index + 1, length) if @match + if current_token == @fragment || wildcard? + return self if tokens.empty? + return @match.search(tokens) if @match end - return @left.node(tokens, index, length) if @left && part < @fragment - return @right.node(tokens, index, length) if @right + return @left.search(tokens, current_token) if @left && current_token < @fragment + return @right.search(tokens, current_token) if @right end + # Inserts or updates tree nodes # - # Searches for path and returns action if matched. - # Returns nil if not found. - # - def search(path) - if result = node(path) - result.action - else - nil - end + # @return [ Route ] The inserted node + def insert(action, tokens) + (leaf = find_or_create_node!(tokens)).action = action + leaf end + # Finds or create nodes for provided tokens, if a node is not found for a + # token, a "blank" node will be created and the search will continue. # - # Inserts str and value into tree. - # - # str must implement [] - # - def insert(tokens, action = nil, index = 0, length = tokens.size) + # @return [ Route ] The node for a set of tokens + def find_or_create_node!(tokens, index = 0) part = tokens[index] + # This will extend the current node with "complex wildcard behavior" / + # n-way search tree + return replace!(tokens, index) if should_replace?(part) + if @fragment.nil? - assign! part, tokens - elsif @fragment[0] == ?: - replace! part, tokens, index + @fragment = fragment_from_token(part) + # Removes "extra" tokens + @tokens = tokens[0..index] end - if part == @fragment then - # We have a match! - - if (index + 1) < length then - # There are more fragments to consume. - (@match ||= Route.new).insert(tokens, action, index + 1, length) - else - # There are no more fragments to consume. - @action = action - end - elsif part < @fragment then - (@left ||= Route.new).insert(tokens, action, index, length) - else - (@right ||= Route.new).insert(tokens, action, index, length) + is_last_token = index == tokens.size - 1 + + # Ensures "virtual" wildcard nodes have the right tokens set so + # that we can map parameters back to route handlers + @tokens = tokens[0..index] if wildcard? && is_last_token + + # Wildcard routes should always be considered matches + direction = wildcard?? MATCH : part <=> @fragment + + # If it is a match and there are no more fragments to consume + return self if is_last_token && direction == MATCH + + case direction + when MATCH + (@match ||= Route.new).find_or_create_node!(tokens, index + 1) + when LEFT + (@left ||= Route.new).find_or_create_node!(tokens, index) + when RIGHT + (@right ||= Route.new).find_or_create_node!(tokens, index) end end - def assign!(fragment, tokens) - @fragment = fragment - @tokens = tokens + def wildcard? + @fragment == WILDCARD_FRAGMENT end - def replace!(fragment, tokens, index, length = tokens.size) - @fragment = fragment - - # Valid routes are always to the left of Wildcards. - @left = Route.new - @left.insert(@tokens, @action, index, @tokens.size) + def fragment_from_token(token) + (token[0] == WILDCARD_CHAR) ? WILDCARD_FRAGMENT : token + end - @tokens = tokens + def should_replace?(part) + # On a wildcard node with an incoming "non-wildcard" node + (wildcard? && part[0] != WILDCARD_CHAR) || + # ... or on a "non-wildcard" node with an incoming wildcard node + !@fragment.nil? && !wildcard? && part[0] == WILDCARD_CHAR + end - # If the Wildcard had additional fragments below it... - if @match - @left.match.insert(@left.tokens, @match.action, index + 1, @left.tokens.size) - end + def replace!(tokens, index) + extend WildcardRoute + find_or_create_node!(tokens, index) + end - # Continue insertion of the new path. - @match = Route.new - @match.insert(tokens, action, index + 1, length) + def assign_from(other_node) + @left = other_node.left + @right = other_node.right + @action = other_node.action + @tokens = other_node.tokens + @fragment = other_node.fragment + @match = other_node.match + self end - end # Route - end # Router -end # Harbor \ No newline at end of file + def reset! + @left = nil + @right = nil + @match = nil + @action = nil + @tokens = nil + @fragment = nil + end + end + end +end diff --git a/lib/harbor/router/route_node.rb b/lib/harbor/router/route_node.rb deleted file mode 100644 index e2c3a11..0000000 --- a/lib/harbor/router/route_node.rb +++ /dev/null @@ -1,119 +0,0 @@ -module Harbor - class Router - # A Ternary Search tree implementation that can be extended to a n-way search - # tree at insertion time. It also uses the AVL algorithm for self balancing (TODO) - class RouteNode - MATCH = 0 - RIGHT = 1 - LEFT = -1 - WILDCARD_FRAGMENT = '*' - WILDCARD_CHAR = ?: - - attr_reader :fragment, :tokens - attr_accessor :action, :left, :right, :match - - def initialize(action = nil) - @action = action - end - - # Basic ternary search tree algorithm - def search(tokens, current_token = nil) - current_token = tokens.shift unless current_token - - if current_token == @fragment || wildcard? - return self if tokens.empty? - return @match.search(tokens) if @match - end - - return @left.search(tokens, current_token) if @left && current_token < @fragment - return @right.search(tokens, current_token) if @right - end - - # Inserts or updates tree nodes - # - # @return [ RouteNode ] The inserted node - def insert(action, tokens) - (leaf = find_or_create_node!(tokens)).action = action - leaf - end - - # Finds or create nodes for provided tokens, if a node is not found for a - # token, a "blank" node will be created and the search will continue. - # - # @return [ RouteNode ] The node for a set of tokens - def find_or_create_node!(tokens, index = 0) - part = tokens[index] - - # This will extend the current node with "complex wildcard behavior" / - # n-way search tree - return replace!(tokens, index) if should_replace?(part) - - if @fragment.nil? - @fragment = fragment_from_token(part) - # Removes "extra" tokens - @tokens = tokens[0..index] - end - - is_last_token = index == tokens.size - 1 - - # Ensures "virtual" wildcard nodes have the right tokens set so - # that we can map parameters back to route handlers - @tokens = tokens[0..index] if wildcard? && is_last_token - - # Wildcard routes should always be considered matches - direction = wildcard?? MATCH : part <=> @fragment - - # If it is a match and there are no more fragments to consume - return self if is_last_token && direction == MATCH - - case direction - when MATCH - (@match ||= RouteNode.new).find_or_create_node!(tokens, index + 1) - when LEFT - (@left ||= RouteNode.new).find_or_create_node!(tokens, index) - when RIGHT - (@right ||= RouteNode.new).find_or_create_node!(tokens, index) - end - end - - def wildcard? - @fragment == WILDCARD_FRAGMENT - end - - def fragment_from_token(token) - (token[0] == WILDCARD_CHAR) ? WILDCARD_FRAGMENT : token - end - - def should_replace?(part) - # On a wildcard node with an incoming "non-wildcard" node - (wildcard? && part[0] != WILDCARD_CHAR) || - # ... or on a "non-wildcard" node with an incoming wildcard node - !@fragment.nil? && !wildcard? && part[0] == WILDCARD_CHAR - end - - def replace!(tokens, index) - extend WildcardRouteNode - find_or_create_node!(tokens, index) - end - - def assign_from(other_node) - @left = other_node.left - @right = other_node.right - @action = other_node.action - @tokens = other_node.tokens - @fragment = other_node.fragment - @match = other_node.match - self - end - - def reset! - @left = nil - @right = nil - @match = nil - @action = nil - @tokens = nil - @fragment = nil - end - end - end -end diff --git a/lib/harbor/router/tree.rb b/lib/harbor/router/tree.rb index 28f1b6c..7ca24a6 100644 --- a/lib/harbor/router/tree.rb +++ b/lib/harbor/router/tree.rb @@ -5,15 +5,16 @@ class Tree def insert(tokens, action) if tokens.empty? - @home = RouteNode.new(action) + @home = Route.new(action) else - (@root ||= RouteNode.new).insert(action, tokens) + (@root ||= Route.new).insert(action, tokens) end self end def search(tokens) - tokens.empty?? home : root.search(tokens) + result = tokens.empty?? home : root.search(tokens) + result.action if result end end end diff --git a/lib/harbor/router/wildcard_route_node.rb b/lib/harbor/router/wildcard_route.rb similarity index 81% rename from lib/harbor/router/wildcard_route_node.rb rename to lib/harbor/router/wildcard_route.rb index 0a8886b..0e40c7e 100644 --- a/lib/harbor/router/wildcard_route_node.rb +++ b/lib/harbor/router/wildcard_route.rb @@ -2,9 +2,9 @@ module Harbor class Router # Used to extend a "simple" node with n-way search tree behavior # TODO: Add inspect information to distinguish from "normal" routes - module WildcardRouteNode + module WildcardRoute def self.extended(base) - new_node = RouteNode.new.assign_from(base) + new_node = Route.new.assign_from(base) if base.wildcard? base.wildcard_tree = new_node else @@ -32,9 +32,9 @@ def search(tokens) def find_or_create_node!(tokens, index = 0) part = tokens[index] if part[0] == self.class::WILDCARD_CHAR - (@wildcard_tree ||= RouteNode.new).find_or_create_node!(tokens, index) + (@wildcard_tree ||= Route.new).find_or_create_node!(tokens, index) else - (trees[part] ||= RouteNode.new).find_or_create_node!(tokens, index) + (trees[part] ||= Route.new).find_or_create_node!(tokens, index) end end diff --git a/test/router/route_node_test.rb b/test/router/route_test.rb similarity index 94% rename from test/router/route_node_test.rb rename to test/router/route_test.rb index 54d401c..553d446 100644 --- a/test/router/route_node_test.rb +++ b/test/router/route_test.rb @@ -1,9 +1,9 @@ require_relative "../helper" module Router - class RouteNodeTest < MiniTest::Unit::TestCase + class RouteTest < MiniTest::Unit::TestCase def setup - @node = Harbor::Router::RouteNode.new + @node = Harbor::Router::Route.new @node.insert(:index, ['posts']) end @@ -80,7 +80,7 @@ def test_handles_non_wildcard_routes_precedence_by_extending_existing_wildcard_r @node.insert(:id, ['posts', ':id']) @node.insert(:recent, ['posts', 'recent']) - assert_kind_of Harbor::Router::WildcardRouteNode, @node.match + assert_kind_of Harbor::Router::WildcardRoute, @node.match refute_nil @node.match.trees end @@ -88,7 +88,7 @@ def test_handles_non_wildcard_routes_precedence_by_extending_existing_simple_rou @node.insert(:recent, ['posts', 'recent']) @node.insert(:id, ['posts', ':id']) - assert_kind_of Harbor::Router::WildcardRouteNode, @node.match + assert_kind_of Harbor::Router::WildcardRoute, @node.match refute_nil @node.match.trees end diff --git a/test/router/tree_test.rb b/test/router/tree_test.rb index 303c603..d47abc6 100644 --- a/test/router/tree_test.rb +++ b/test/router/tree_test.rb @@ -1,6 +1,4 @@ -require "minitest/autorun" -require_relative '../../lib/harbor/router/tree' -require_relative '../../lib/harbor/router/route_node' +require_relative "../helper" module Router class TreeTest < MiniTest::Unit::TestCase @@ -30,17 +28,15 @@ def test_delegates_insertion_to_root_node def test_delegates_search_to_root_node mock = MiniTest::Mock.new - mock.expect :search, nil, [['posts']] + mock.expect :search, Harbor::Router::Route.new(:search_result), [['posts']] @tree.instance_variable_set(:@root, mock) - @tree.search(['posts']) - - assert mock.verify + assert_equal :search_result, @tree.search(['posts']) end def test_matches_home_route_if_registered @tree.insert([], :home) - assert_equal :home, @tree.search([]).action + assert_equal :home, @tree.search([]) end end end diff --git a/test/router/wildcard_route_node_test.rb b/test/router/wildcard_route_test.rb similarity index 95% rename from test/router/wildcard_route_node_test.rb rename to test/router/wildcard_route_test.rb index 412eb15..9a5079e 100644 --- a/test/router/wildcard_route_node_test.rb +++ b/test/router/wildcard_route_test.rb @@ -1,9 +1,9 @@ require_relative "../helper" module Router - class WildcardRouteNodeTest < MiniTest::Unit::TestCase + class WildcardRouteTest < MiniTest::Unit::TestCase def setup - @node = Harbor::Router::RouteNode.new + @node = Harbor::Router::Route.new end def test_creates_tree_entry_for_exact_matches_when_extended From bb18b0c975cb3c47b32e575a7dfd034c2d919cc5 Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 20:57:51 -0300 Subject: [PATCH 07/10] These tests are green with new router tree \o/ --- test/router_test.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/router_test.rb b/test/router_test.rb index faf0259..ccb1eba 100644 --- a/test/router_test.rb +++ b/test/router_test.rb @@ -14,8 +14,6 @@ def setup end def test_index_route - flunk "The new router doesn't support homepages yet" - assert_equal(:index, @router.match("GET", "/").call) end @@ -32,16 +30,12 @@ def test_wildcard_route_matches end def test_route_under_wildcard_matches - flunk "This fails after having added the 4th route: /parts/:part_id/orders/:order_id" - assert_route_matches("GET", "parts/42/orders") do |action| assert_equal(:get_orders_for_part, action.call) end end def test_route_ending_in_wildcard_matches - flunk - assert_route_matches("GET", "parts/42/orders/1337") do |action| assert_equal(:get_order_for_part, action.call) end From d1f7d65067e3b37939f62f549deb9ea0b8ef3b2d Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 21:05:47 -0300 Subject: [PATCH 08/10] Map "/" route to __root__ action and add test for router to check routing precedence --- lib/harbor/controller.rb | 1 + lib/harbor/controller/normalized_path.rb | 3 +-- test/controller_test.rb | 9 ++++++++- test/router_test.rb | 6 ++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/harbor/controller.rb b/lib/harbor/controller.rb index 8d172f2..b973f99 100644 --- a/lib/harbor/controller.rb +++ b/lib/harbor/controller.rb @@ -48,6 +48,7 @@ def self.route(method, path, handler) end def self.method_name_for_route(http_method, path) + return "GET__root__" if path == "/" parts = [ http_method.upcase ] Router::Route::expand(path).each do |part| diff --git a/lib/harbor/controller/normalized_path.rb b/lib/harbor/controller/normalized_path.rb index 9ecd3cc..a5f6d89 100644 --- a/lib/harbor/controller/normalized_path.rb +++ b/lib/harbor/controller/normalized_path.rb @@ -28,7 +28,6 @@ def to_s end end end - end end -end \ No newline at end of file +end diff --git a/test/controller_test.rb b/test/controller_test.rb index a77e988..1675d46 100644 --- a/test/controller_test.rb +++ b/test/controller_test.rb @@ -10,6 +10,11 @@ class Foos < Harbor::Controller :GET end + # / + get "/" do + :GET__root__ + end + # /foos/executive_report get "executive_report" do :GET_executive_report @@ -46,7 +51,8 @@ def setup @router = Harbor::Router::instance end - def test_generated_action_methods_return_exepected_results + def test_generated_action_methods_return_expected_results + assert_equal :GET__root__, @example.GET__root__ assert_equal :GET, @example.GET assert_equal :GET_executive_report, @example.GET_executive_report assert_equal :GET__id, @example.GET__id @@ -56,6 +62,7 @@ def test_generated_action_methods_return_exepected_results end def test_generated_routes_match_actions + assert_controller_route_matches("GET", "/", Controllers::Foos, :GET__root__) assert_controller_route_matches("GET", "/controller_test/foos", Controllers::Foos, :GET) assert_controller_route_matches("GET", "/controller_test/foos/executive_report", Controllers::Foos, :GET_executive_report) assert_controller_route_matches("GET", "/controller_test/foos/42", Controllers::Foos, :GET__id) diff --git a/test/router_test.rb b/test/router_test.rb index ccb1eba..3c8e8c8 100644 --- a/test/router_test.rb +++ b/test/router_test.rb @@ -11,6 +11,7 @@ def setup @router.register("GET", "/parts/:id", lambda { :get_part_by_id }) @router.register("GET", "/parts/:id/orders", lambda { :get_orders_for_part }) @router.register("GET", "/parts/:part_id/orders/:order_id", lambda { :get_order_for_part }) + @router.register("GET", "/parts/discontinued", lambda { :get_discontinued_parts }) end def test_index_route @@ -41,4 +42,9 @@ def test_route_ending_in_wildcard_matches end end + def test_non_wildcard_route_takes_precedence_over_wildcard_ones + assert_route_matches("GET", "parts/discontinued") do |action| + assert_equal(:get_discontinued_parts, action.call) + end + end end From fbf298ed733c28c32cdc1b6b041da17bab7cb9a8 Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 21:55:24 -0300 Subject: [PATCH 09/10] Update Gemfile.lock --- Gemfile.lock | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5de282c..049094d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,3 @@ -GIT - remote: git@github.com:sam/redis_directory.git - revision: ecd2fbc6613bbb0146895f021a86c8de0ea05e1e - specs: - redis_directory (1.0.4) - json - redis - PATH remote: . specs: @@ -47,6 +39,7 @@ GEM macaddr (1.5.0) systemu (>= 2.4.0) mime-types (1.17.2) + minitest (2.11.3) rack (1.4.1) rack-test (0.6.1) rack (>= 1.0) @@ -54,6 +47,10 @@ GEM rdoc (3.12) json (~> 1.4) redis (2.2.2) + redis_directory (1.0.4) + json + minitest + redis systemu (2.4.2) testdrive (0.2) tilt (1.3.3) @@ -72,6 +69,6 @@ DEPENDENCIES rack-test rake rdoc (>= 2.4.2) - redis_directory! + redis_directory (>= 1.0.4) testdrive uuid From 2c464a3bbea9673bb16574c570d5a7d94cc6b464 Mon Sep 17 00:00:00 2001 From: Fabio Rehm Date: Sun, 4 Mar 2012 22:03:29 -0300 Subject: [PATCH 10/10] There's no need to require 'set' anymore --- lib/harbor/router.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/harbor/router.rb b/lib/harbor/router.rb index cd34a50..fa7cae7 100644 --- a/lib/harbor/router.rb +++ b/lib/harbor/router.rb @@ -1,4 +1,3 @@ -require "set" require_relative "router/tree" require_relative "router/route" require_relative "router/wildcard_route"