mirror of
https://gitea.invidious.io/iv-org/shard-radix.git
synced 2024-08-15 00:43:21 +00:00
Extraction: initial import
Extract Radix Tree implementation from `Beryl` project into an standalone library to facilitate usage by other developers. - Move `Tree`, `Node` and `Result` into `Radix` namespace - Clenaup standalone README and describe usage
This commit is contained in:
commit
7f348cae8c
13 changed files with 1229 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
/doc/
|
||||
/libs/
|
||||
/.crystal/
|
||||
/.shards/
|
||||
|
||||
# Libraries don't need dependency lock
|
||||
# Dependencies will be locked in application that uses them
|
||||
/shard.lock
|
1
.travis.yml
Normal file
1
.travis.yml
Normal file
|
@ -0,0 +1 @@
|
|||
language: crystal
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Luis Lavena
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
68
README.md
Normal file
68
README.md
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Radix Tree
|
||||
|
||||
[Radix tree](https://en.wikipedia.org/wiki/Radix_tree) implementation for
|
||||
Crystal language
|
||||
|
||||
## Installation
|
||||
|
||||
Add this to your application's `shard.yml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
radix:
|
||||
github: luislavena/radix
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
You can associate a *payload* with each path added to the tree:
|
||||
|
||||
```crystal
|
||||
require "radix"
|
||||
|
||||
tree = Radix::Tree.new
|
||||
tree.add "/products", :products
|
||||
tree.add "/products/featured", :featured
|
||||
|
||||
result = tree.find "/products/featured"
|
||||
|
||||
if result.found?
|
||||
puts result.payload # => :featured
|
||||
end
|
||||
```
|
||||
|
||||
You can also extract values from placeholders (as named segments or globbing):
|
||||
|
||||
```
|
||||
tree.add "/products/:id", :product
|
||||
|
||||
result = tree.find "/products/1234"
|
||||
|
||||
if result.found?
|
||||
puts result.params["id"]? # => "1234"
|
||||
end
|
||||
```
|
||||
|
||||
Please see `Radix::Tree#add` documentation for more usage examples.
|
||||
|
||||
## Implementation
|
||||
|
||||
This project has been inspired and adapted from
|
||||
[julienschmidt/httprouter](https://github.com/julienschmidt/httprouter) and
|
||||
[spriet2000/vertx-http-router](https://github.com/spriet2000/vertx-http-router)
|
||||
Go and Java implementations, respectively.
|
||||
|
||||
Changes to logic and optimizations have been made to take advantage of
|
||||
Crystal's features.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it ( https://github.com/luislavena/crystal-beryl/fork )
|
||||
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
4. Push to the branch (`git push origin my-new-feature`)
|
||||
5. Create a new Pull Request
|
||||
|
||||
## Contributors
|
||||
|
||||
- [Luis Lavena](https://github.com/luislavena) - creator, maintainer
|
7
shard.yml
Normal file
7
shard.yml
Normal file
|
@ -0,0 +1,7 @@
|
|||
name: radix
|
||||
version: 0.1.0
|
||||
|
||||
authors:
|
||||
- Luis Lavena <luislavena@gmail.com>
|
||||
|
||||
license: MIT
|
102
spec/radix/node_spec.cr
Normal file
102
spec/radix/node_spec.cr
Normal file
|
@ -0,0 +1,102 @@
|
|||
require "../spec_helper"
|
||||
|
||||
module Radix
|
||||
describe Node do
|
||||
describe "#key=" do
|
||||
it "accepts change of key after initialization" do
|
||||
node = Node.new("abc")
|
||||
node.key.should eq("abc")
|
||||
|
||||
node.key = "xyz"
|
||||
node.key.should eq("xyz")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#payload" do
|
||||
it "accepts any form of payload" do
|
||||
node = Node.new("abc", :payload)
|
||||
node.payload?.should be_truthy
|
||||
node.payload.should eq(:payload)
|
||||
|
||||
node = Node.new("abc", 1_000)
|
||||
node.payload?.should be_truthy
|
||||
node.payload.should eq(1_000)
|
||||
end
|
||||
|
||||
it "makes optional to provide a payload" do
|
||||
node = Node.new("abc")
|
||||
node.payload?.should be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
describe "#priority" do
|
||||
it "calculates it based on key size" do
|
||||
node = Node.new("a")
|
||||
node.priority.should eq(1)
|
||||
|
||||
node = Node.new("abc")
|
||||
node.priority.should eq(3)
|
||||
end
|
||||
|
||||
it "returns zero for catch all (globbed) key" do
|
||||
node = Node.new("*filepath")
|
||||
node.priority.should eq(0)
|
||||
|
||||
node = Node.new("/src/*filepath")
|
||||
node.priority.should eq(0)
|
||||
end
|
||||
|
||||
it "returns one for keys with named parameters" do
|
||||
node = Node.new(":query")
|
||||
node.priority.should eq(1)
|
||||
|
||||
node = Node.new("/search/:query")
|
||||
node.priority.should eq(1)
|
||||
end
|
||||
|
||||
it "changes when key changes" do
|
||||
node = Node.new("a")
|
||||
node.priority.should eq(1)
|
||||
|
||||
node.key = "abc"
|
||||
node.priority.should eq(3)
|
||||
|
||||
node.key = "*filepath"
|
||||
node.priority.should eq(0)
|
||||
|
||||
node.key = ":query"
|
||||
node.priority.should eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#sort!" do
|
||||
it "orders children by priority" do
|
||||
root = Node.new("/")
|
||||
node1 = Node.new("a")
|
||||
node2 = Node.new("bc")
|
||||
node3 = Node.new("def")
|
||||
|
||||
root.children.push(node1, node2, node3)
|
||||
root.sort!
|
||||
|
||||
root.children[0].should eq(node3)
|
||||
root.children[1].should eq(node2)
|
||||
root.children[2].should eq(node1)
|
||||
end
|
||||
|
||||
it "orders catch all and named parameters lower than others" do
|
||||
root = Node.new("/")
|
||||
node1 = Node.new("*filepath")
|
||||
node2 = Node.new("abc")
|
||||
node3 = Node.new(":query")
|
||||
|
||||
root.children.push(node1, node2, node3)
|
||||
root.sort!
|
||||
|
||||
root.children[0].should eq(node2)
|
||||
root.children[1].should eq(node3)
|
||||
root.children[2].should eq(node1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
67
spec/radix/result_spec.cr
Normal file
67
spec/radix/result_spec.cr
Normal file
|
@ -0,0 +1,67 @@
|
|||
require "../spec_helper"
|
||||
|
||||
module Radix
|
||||
describe Result do
|
||||
describe "#found?" do
|
||||
context "a new instance" do
|
||||
it "returns false when no payload is associated" do
|
||||
result = Result.new
|
||||
result.found?.should be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with a payload" do
|
||||
it "returns true" do
|
||||
node = Node.new("/", :root)
|
||||
result = Result.new
|
||||
result.use node
|
||||
|
||||
result.found?.should be_true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#key" do
|
||||
context "a new instance" do
|
||||
it "returns an empty key" do
|
||||
result = Result.new
|
||||
result.key.should eq("")
|
||||
end
|
||||
end
|
||||
|
||||
context "given one used node" do
|
||||
it "returns the node key" do
|
||||
node = Node.new("/", :root)
|
||||
result = Result.new
|
||||
result.use node
|
||||
|
||||
result.key.should eq("/")
|
||||
end
|
||||
end
|
||||
|
||||
context "using multiple nodes" do
|
||||
it "combines the node keys" do
|
||||
node1 = Node.new("/", :root)
|
||||
node2 = Node.new("about", :about)
|
||||
result = Result.new
|
||||
result.use node1
|
||||
result.use node2
|
||||
|
||||
result.key.should eq("/about")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#use" do
|
||||
it "uses the node payload" do
|
||||
node = Node.new("/", :root)
|
||||
result = Result.new
|
||||
result.payload?.should be_falsey
|
||||
|
||||
result.use node
|
||||
result.payload?.should be_truthy
|
||||
result.payload.should eq(node.payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
371
spec/radix/tree_spec.cr
Normal file
371
spec/radix/tree_spec.cr
Normal file
|
@ -0,0 +1,371 @@
|
|||
require "../spec_helper"
|
||||
|
||||
module Radix
|
||||
describe Tree do
|
||||
context "a new instance" do
|
||||
it "contains a root placeholder node" do
|
||||
tree = Tree.new
|
||||
tree.root.should be_a(Node)
|
||||
tree.root.payload?.should be_falsey
|
||||
tree.root.placeholder?.should be_true
|
||||
end
|
||||
end
|
||||
|
||||
describe "#add" do
|
||||
context "on a new instance" do
|
||||
it "replaces placeholder with new node" do
|
||||
tree = Tree.new
|
||||
tree.add "/abc", :abc
|
||||
tree.root.should be_a(Node)
|
||||
tree.root.placeholder?.should be_false
|
||||
tree.root.payload?.should be_truthy
|
||||
tree.root.payload.should eq(:abc)
|
||||
end
|
||||
end
|
||||
|
||||
context "shared root" do
|
||||
it "inserts properly adjacent nodes" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/a", :a
|
||||
tree.add "/bc", :bc
|
||||
|
||||
# / (:root)
|
||||
# +-bc (:bc)
|
||||
# \-a (:a)
|
||||
tree.root.children.size.should eq(2)
|
||||
tree.root.children[0].key.should eq("bc")
|
||||
tree.root.children[0].payload.should eq(:bc)
|
||||
tree.root.children[1].key.should eq("a")
|
||||
tree.root.children[1].payload.should eq(:a)
|
||||
end
|
||||
|
||||
it "inserts nodes with shared parent" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/abc", :abc
|
||||
tree.add "/axyz", :axyz
|
||||
|
||||
# / (:root)
|
||||
# +-a
|
||||
# +-xyz (:axyz)
|
||||
# \-bc (:abc)
|
||||
tree.root.children.size.should eq(1)
|
||||
tree.root.children[0].key.should eq("a")
|
||||
tree.root.children[0].children.size.should eq(2)
|
||||
tree.root.children[0].children[0].key.should eq("xyz")
|
||||
tree.root.children[0].children[1].key.should eq("bc")
|
||||
end
|
||||
|
||||
it "inserts multiple parent nodes" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/admin/users", :users
|
||||
tree.add "/admin/products", :products
|
||||
tree.add "/blog/tags", :tags
|
||||
tree.add "/blog/articles", :articles
|
||||
|
||||
# / (:root)
|
||||
# +-admin/
|
||||
# | +-products (:products)
|
||||
# | \-users (:users)
|
||||
# |
|
||||
# +-blog/
|
||||
# +-articles (:articles)
|
||||
# \-tags (:tags)
|
||||
tree.root.children.size.should eq(2)
|
||||
tree.root.children[0].key.should eq("admin/")
|
||||
tree.root.children[0].payload?.should be_falsey
|
||||
tree.root.children[0].children[0].key.should eq("products")
|
||||
tree.root.children[0].children[1].key.should eq("users")
|
||||
tree.root.children[1].key.should eq("blog/")
|
||||
tree.root.children[1].payload?.should be_falsey
|
||||
tree.root.children[1].children[0].key.should eq("articles")
|
||||
tree.root.children[1].children[0].payload?.should be_truthy
|
||||
tree.root.children[1].children[1].key.should eq("tags")
|
||||
tree.root.children[1].children[1].payload?.should be_truthy
|
||||
end
|
||||
|
||||
it "inserts multiple nodes with mixed parents" do
|
||||
tree = Tree.new
|
||||
tree.add "/authorizations", :authorizations
|
||||
tree.add "/authorizations/:id", :authorization
|
||||
tree.add "/applications", :applications
|
||||
tree.add "/events", :events
|
||||
|
||||
# /
|
||||
# +-events (:events)
|
||||
# +-a
|
||||
# +-uthorizations (:authorizations)
|
||||
# | \-/:id (:authorization)
|
||||
# \-pplications (:applications)
|
||||
tree.root.children.size.should eq(2)
|
||||
tree.root.children[1].key.should eq("a")
|
||||
tree.root.children[1].children.size.should eq(2)
|
||||
tree.root.children[1].children[0].payload.should eq(:authorizations)
|
||||
tree.root.children[1].children[1].payload.should eq(:applications)
|
||||
end
|
||||
|
||||
it "supports insertion of mixed routes out of order" do
|
||||
tree = Tree.new
|
||||
tree.add "/user/repos", :my_repos
|
||||
tree.add "/users/:user/repos", :user_repos
|
||||
tree.add "/users/:user", :user
|
||||
tree.add "/user", :me
|
||||
|
||||
# /user (:me)
|
||||
# +-/repos (:my_repos)
|
||||
# \-s/:user (:user)
|
||||
# \-/repos (:user_repos)
|
||||
tree.root.key.should eq("/user")
|
||||
tree.root.payload?.should be_truthy
|
||||
tree.root.payload.should eq(:me)
|
||||
tree.root.children.size.should eq(2)
|
||||
tree.root.children[0].key.should eq("/repos")
|
||||
tree.root.children[1].key.should eq("s/:user")
|
||||
tree.root.children[1].payload.should eq(:user)
|
||||
tree.root.children[1].children[0].key.should eq("/repos")
|
||||
end
|
||||
end
|
||||
|
||||
context "dealing with duplicates" do
|
||||
it "does not allow same path be defined twice" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/abc", :abc
|
||||
|
||||
expect_raises Tree::DuplicateError do
|
||||
tree.add "/", :other
|
||||
end
|
||||
|
||||
tree.root.children.size.should eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context "dealing with catch all and named parameters" do
|
||||
it "prioritizes nodes correctly" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/*filepath", :all
|
||||
tree.add "/products", :products
|
||||
tree.add "/products/:id", :product
|
||||
tree.add "/products/:id/edit", :edit
|
||||
tree.add "/products/featured", :featured
|
||||
|
||||
# / (:all)
|
||||
# +-products (:products)
|
||||
# | \-/
|
||||
# | +-featured (:featured)
|
||||
# | \-:id (:product)
|
||||
# | \-/edit (:edit)
|
||||
# \-*filepath (:all)
|
||||
tree.root.children.size.should eq(2)
|
||||
tree.root.children[0].key.should eq("products")
|
||||
tree.root.children[0].children[0].key.should eq("/")
|
||||
|
||||
nodes = tree.root.children[0].children[0].children
|
||||
nodes.size.should eq(2)
|
||||
nodes[0].key.should eq("featured")
|
||||
nodes[1].key.should eq(":id")
|
||||
nodes[1].children[0].key.should eq("/edit")
|
||||
|
||||
tree.root.children[1].key.should eq("*filepath")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#find" do
|
||||
context "a single node" do
|
||||
it "does not find when using different path" do
|
||||
tree = Tree.new
|
||||
tree.add "/about", :about
|
||||
|
||||
result = tree.find "/products"
|
||||
result.found?.should be_false
|
||||
end
|
||||
|
||||
it "finds when using matching path" do
|
||||
tree = Tree.new
|
||||
tree.add "/about", :about
|
||||
|
||||
result = tree.find "/about"
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/about")
|
||||
result.payload?.should be_truthy
|
||||
result.payload.should eq(:about)
|
||||
end
|
||||
|
||||
it "finds when using path with trailing slash" do
|
||||
tree = Tree.new
|
||||
tree.add "/about", :about
|
||||
|
||||
result = tree.find "/about/"
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/about")
|
||||
end
|
||||
|
||||
it "finds when key has trailing slash" do
|
||||
tree = Tree.new
|
||||
tree.add "/about/", :about
|
||||
|
||||
result = tree.find "/about"
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/about/")
|
||||
result.payload.should eq(:about)
|
||||
end
|
||||
end
|
||||
|
||||
context "nodes with shared parent" do
|
||||
it "finds matching path" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/abc", :abc
|
||||
tree.add "/axyz", :axyz
|
||||
|
||||
result = tree.find("/abc")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/abc")
|
||||
result.payload.should eq(:abc)
|
||||
end
|
||||
|
||||
it "finds matching path across parents" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/admin/users", :users
|
||||
tree.add "/admin/products", :products
|
||||
tree.add "/blog/tags", :tags
|
||||
tree.add "/blog/articles", :articles
|
||||
|
||||
result = tree.find("/blog/tags/")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/blog/tags")
|
||||
result.payload.should eq(:tags)
|
||||
end
|
||||
end
|
||||
|
||||
context "dealing with catch all" do
|
||||
it "finds matching path" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/*filepath", :all
|
||||
tree.add "/about", :about
|
||||
|
||||
result = tree.find("/src/file.png")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/*filepath")
|
||||
result.payload.should eq(:all)
|
||||
end
|
||||
|
||||
it "returns catch all in parameters" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/*filepath", :all
|
||||
tree.add "/about", :about
|
||||
|
||||
result = tree.find("/src/file.png")
|
||||
result.found?.should be_true
|
||||
result.params.has_key?("filepath").should be_true
|
||||
result.params["filepath"].should eq("src/file.png")
|
||||
end
|
||||
|
||||
it "returns optional catch all" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/search/*extra", :extra
|
||||
|
||||
result = tree.find("/search")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/search/*extra")
|
||||
result.params.has_key?("extra").should be_true
|
||||
result.params["extra"].empty?.should be_true
|
||||
end
|
||||
|
||||
it "does not find when catch all is not full match" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/search/public/*query", :search
|
||||
|
||||
result = tree.find("/search")
|
||||
result.found?.should be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "dealing with named parameters" do
|
||||
it "finds matching path" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/products", :products
|
||||
tree.add "/products/:id", :product
|
||||
tree.add "/products/:id/edit", :edit
|
||||
|
||||
result = tree.find("/products/10")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/products/:id")
|
||||
result.payload.should eq(:product)
|
||||
end
|
||||
|
||||
it "does not find partial matching path" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/products", :products
|
||||
tree.add "/products/:id/edit", :edit
|
||||
|
||||
result = tree.find("/products/10")
|
||||
result.found?.should be_false
|
||||
end
|
||||
|
||||
it "returns named parameters in result" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/products", :products
|
||||
tree.add "/products/:id", :product
|
||||
tree.add "/products/:id/edit", :edit
|
||||
|
||||
result = tree.find("/products/10/edit")
|
||||
result.found?.should be_true
|
||||
result.params.has_key?("id").should be_true
|
||||
result.params["id"].should eq("10")
|
||||
end
|
||||
|
||||
it "returns unicode values in parameters" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/language/:name", :language
|
||||
tree.add "/language/:name/about", :about
|
||||
|
||||
result = tree.find("/language/日本語")
|
||||
result.found?.should be_true
|
||||
result.params.has_key?("name").should be_true
|
||||
result.params["name"].should eq("日本語")
|
||||
end
|
||||
end
|
||||
|
||||
context "dealing with both catch all and named parameters" do
|
||||
it "finds matching path" do
|
||||
tree = Tree.new
|
||||
tree.add "/", :root
|
||||
tree.add "/*filepath", :all
|
||||
tree.add "/products", :products
|
||||
tree.add "/products/:id", :product
|
||||
tree.add "/products/:id/edit", :edit
|
||||
tree.add "/products/featured", :featured
|
||||
|
||||
result = tree.find("/products/1000")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/products/:id")
|
||||
result.payload.should eq(:product)
|
||||
|
||||
result = tree.find("/admin/articles")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/*filepath")
|
||||
result.params["filepath"].should eq("admin/articles")
|
||||
|
||||
result = tree.find("/products/featured")
|
||||
result.found?.should be_true
|
||||
result.key.should eq("/products/featured")
|
||||
result.payload.should eq(:featured)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
2
spec/spec_helper.cr
Normal file
2
spec/spec_helper.cr
Normal file
|
@ -0,0 +1,2 @@
|
|||
require "spec"
|
||||
require "../src/radix"
|
1
src/radix.cr
Normal file
1
src/radix.cr
Normal file
|
@ -0,0 +1 @@
|
|||
require "./radix/tree"
|
133
src/radix/node.cr
Normal file
133
src/radix/node.cr
Normal file
|
@ -0,0 +1,133 @@
|
|||
module Radix
|
||||
# A Node represents one element in the structure of a [Radix tree](https://en.wikipedia.org/wiki/Radix_tree)
|
||||
#
|
||||
# Carries a *payload* and might also contain references to other nodes
|
||||
# down in the organization inside *children*.
|
||||
#
|
||||
# Each node also carries a *priority* number, which might indicate the
|
||||
# weight of this node depending on characteristics like catch all
|
||||
# (globbing), named parameters or simply the size of the Node's *key*.
|
||||
#
|
||||
# Referenced nodes inside *children* can be sorted by *priority*.
|
||||
#
|
||||
# Is not expected direct usage of a node but instead manipulation via
|
||||
# methods within `Tree`.
|
||||
#
|
||||
# ```
|
||||
# node = Node.new("/", :root)
|
||||
# node.children << Node.new("a", :a)
|
||||
# node.children << Node.new("bc", :bc)
|
||||
# node.children << Node.new("def", :def)
|
||||
# node.sort!
|
||||
#
|
||||
# node.priority
|
||||
# # => 1
|
||||
#
|
||||
# node.children.map &.priority
|
||||
# # => [3, 2, 1]
|
||||
# ```
|
||||
class Node
|
||||
getter :key
|
||||
getter? :placeholder
|
||||
property! :payload
|
||||
property :children
|
||||
|
||||
# Returns the priority of the Node based on it's *key*
|
||||
#
|
||||
# This value will be directly associated to the key size or special
|
||||
# elements of it.
|
||||
#
|
||||
# * A catch all (globbed) key will receive lowest priority (`0`)
|
||||
# * A named parameter key will receive priority above catch all (`1`)
|
||||
# * Any other type of key will receive priority based on its size.
|
||||
#
|
||||
# ```
|
||||
# Node.new("a").priority
|
||||
# # => 1
|
||||
#
|
||||
# Node.new("abc").priority
|
||||
# # => 3
|
||||
#
|
||||
# Node.new("*filepath").priority
|
||||
# # => 0
|
||||
#
|
||||
# Node.new(":query").priority
|
||||
# # => 1
|
||||
# ```
|
||||
getter :priority
|
||||
|
||||
# Instantiate a Node
|
||||
#
|
||||
# - *key* - A `String` that represents this node.
|
||||
# - *payload* - An Optional payload for this node.
|
||||
def initialize(@key : String, @payload = nil, @placeholder = false)
|
||||
@children = [] of Node
|
||||
@priority = compute_priority
|
||||
end
|
||||
|
||||
# Changes current *key*
|
||||
#
|
||||
# ```
|
||||
# node = Node.new("a")
|
||||
# node.key
|
||||
# # => "a"
|
||||
#
|
||||
# node.key = "b"
|
||||
# node.key
|
||||
# # => "b"
|
||||
# ```
|
||||
#
|
||||
# This will also result in a new priority for the node.
|
||||
#
|
||||
# ```
|
||||
# node = Node.new("a")
|
||||
# node.priority
|
||||
# # => 1
|
||||
#
|
||||
# node.key = "abcdef"
|
||||
# node.priority
|
||||
# # => 6
|
||||
# ```
|
||||
def key=(value : String)
|
||||
@key = value
|
||||
@priority = compute_priority
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
private def compute_priority
|
||||
reader = Char::Reader.new(@key)
|
||||
|
||||
while reader.has_next?
|
||||
case reader.current_char
|
||||
when '*'
|
||||
return 0
|
||||
when ':'
|
||||
return 1
|
||||
else
|
||||
reader.next_char
|
||||
end
|
||||
end
|
||||
|
||||
reader.pos
|
||||
end
|
||||
|
||||
# Changes the order of Node's children based on each node priority.
|
||||
#
|
||||
# This ensures highest priority nodes are listed before others.
|
||||
#
|
||||
# ```
|
||||
# root = Node.new("/")
|
||||
# root.children << Node.new("*filepath") # node.priority => 0
|
||||
# root.children << Node.new(":query") # node.priority => 1
|
||||
# root.children << Node.new("a") # node.priority => 1
|
||||
# root.children << Node.new("bc") # node.priority => 2
|
||||
# root.sort!
|
||||
#
|
||||
# root.children.map &.priority
|
||||
# # => [2, 1, 1, 0]
|
||||
# ```
|
||||
def sort!
|
||||
@children.sort_by! { |node| -node.priority }
|
||||
end
|
||||
end
|
||||
end
|
88
src/radix/result.cr
Normal file
88
src/radix/result.cr
Normal file
|
@ -0,0 +1,88 @@
|
|||
require "./node"
|
||||
|
||||
module Radix
|
||||
# A Result is the comulative output of walking our [Radix tree](https://en.wikipedia.org/wiki/Radix_tree)
|
||||
# `Tree` implementation.
|
||||
#
|
||||
# It provides helpers to retrieve the information obtained from walking
|
||||
# our tree using `Tree#find`
|
||||
#
|
||||
# This information can be used to perform actions in case of the *path*
|
||||
# that was looked on the Tree was found.
|
||||
#
|
||||
# A Result is also used recursively by `Tree#find` when collecting extra
|
||||
# information like *params*.
|
||||
class Result
|
||||
getter :params
|
||||
getter! :payload
|
||||
|
||||
# :nodoc:
|
||||
def initialize
|
||||
@nodes = [] of Node
|
||||
@params = {} of String => String
|
||||
end
|
||||
|
||||
# Returns whatever a *payload* was found by `Tree#find` and is part of
|
||||
# the result.
|
||||
#
|
||||
# ```
|
||||
# result = Result.new
|
||||
# result.found?
|
||||
# # => false
|
||||
#
|
||||
# root = Node.new("/", :root)
|
||||
# result.use(root)
|
||||
# result.found?
|
||||
# # => true
|
||||
# ```
|
||||
def found?
|
||||
payload? ? true : false
|
||||
end
|
||||
|
||||
# Returns a String built based on the nodes used in the result
|
||||
#
|
||||
# ```
|
||||
# node1 = Node.new("/", :root)
|
||||
# node2 = Node.new("about", :about)
|
||||
#
|
||||
# result = Result.new
|
||||
# result.use node1
|
||||
# result.use node2
|
||||
#
|
||||
# result.key
|
||||
# # => "/about"
|
||||
# ```
|
||||
#
|
||||
# When no node has been used, returns an empty String.
|
||||
#
|
||||
# ```
|
||||
# result = Result.new
|
||||
# result.key
|
||||
# # => ""
|
||||
# ```
|
||||
def key
|
||||
return @key if @key
|
||||
|
||||
key = String.build { |io|
|
||||
@nodes.each do |node|
|
||||
io << node.key
|
||||
end
|
||||
}
|
||||
|
||||
@key = key
|
||||
end
|
||||
|
||||
# Adjust result information by using the details of the given `Node`.
|
||||
#
|
||||
# * Collect `Node` for future references.
|
||||
# * Use *payload* if present.
|
||||
def use(node : Node, payload = true)
|
||||
# collect nodes
|
||||
@nodes << node
|
||||
|
||||
if payload && node.payload?
|
||||
@payload = node.payload
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
360
src/radix/tree.cr
Normal file
360
src/radix/tree.cr
Normal file
|
@ -0,0 +1,360 @@
|
|||
require "./node"
|
||||
require "./result"
|
||||
|
||||
module Radix
|
||||
# A [Radix tree](https://en.wikipedia.org/wiki/Radix_tree) implementation.
|
||||
#
|
||||
# It allows insertion of *path* elements that will be organized inside
|
||||
# the tree aiming to provide fast retrieval options.
|
||||
#
|
||||
# Each inserted *path* will be represented by a `Node` or segmented and
|
||||
# distributed within the `Tree`.
|
||||
#
|
||||
# You can associate a *payload* at insertion which will be return back
|
||||
# at retrieval time.
|
||||
class Tree
|
||||
# :nodoc:
|
||||
class DuplicateError < Exception
|
||||
def initialize(path)
|
||||
super("Duplicate trail found '#{path}'")
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the root `Node` element of the Tree.
|
||||
#
|
||||
# On a new tree instance, this will be a placeholder.
|
||||
getter :root
|
||||
|
||||
def initialize
|
||||
@root = Node.new("", placeholder: true)
|
||||
end
|
||||
|
||||
# Inserts given *path* into the Tree
|
||||
#
|
||||
# * *path* - An `String` representing the pattern to be inserted.
|
||||
# * *payload* - Required associated element for this path.
|
||||
#
|
||||
# If no previous elements existed in the Tree, this will replace the
|
||||
# defined placeholder.
|
||||
#
|
||||
# ```
|
||||
# tree = Tree.new
|
||||
#
|
||||
# # / (:root)
|
||||
# tree.add "/", :root
|
||||
#
|
||||
# # / (:root)
|
||||
# # \-abc (:abc)
|
||||
# tree.add "/abc", :abc
|
||||
#
|
||||
# # / (:root)
|
||||
# # \-abc (:abc)
|
||||
# # \-xyz (:xyz)
|
||||
# tree.add "/abcxyz", :xyz
|
||||
# ```
|
||||
#
|
||||
# Nodes inside the tree will be adjusted to accomodate the different
|
||||
# segments of the given *path*.
|
||||
#
|
||||
# ```
|
||||
# tree = Tree.new
|
||||
#
|
||||
# # / (:root)
|
||||
# tree.add "/", :root
|
||||
#
|
||||
# # / (:root)
|
||||
# # \-products/:id (:product)
|
||||
# tree.add "/products/:id", :product
|
||||
#
|
||||
# # / (:root)
|
||||
# # \-products/
|
||||
# # +-featured (:featured)
|
||||
# # \-:id (:product)
|
||||
# tree.add "/products/featured", :featured
|
||||
# ```
|
||||
#
|
||||
# Catch all (globbing) and named paramters *path* will be located with
|
||||
# lower priority against other nodes.
|
||||
#
|
||||
# ```
|
||||
# tree = Tree.new
|
||||
#
|
||||
# # / (:root)
|
||||
# tree.add "/", :root
|
||||
#
|
||||
# # / (:root)
|
||||
# # \-*filepath (:all)
|
||||
# tree.add "/*filepath", :all
|
||||
#
|
||||
# # / (:root)
|
||||
# # +-about (:about)
|
||||
# # \-*filepath (:all)
|
||||
# tree.add "/about", :about
|
||||
# ```
|
||||
def add(path : String, payload)
|
||||
root = @root
|
||||
|
||||
# replace placeholder with new node
|
||||
if root.placeholder?
|
||||
@root = Node.new(path, payload)
|
||||
else
|
||||
add path, payload, root
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
private def add(path : String, payload, node : Node)
|
||||
key_reader = Char::Reader.new(node.key)
|
||||
path_reader = Char::Reader.new(path)
|
||||
|
||||
# move cursor position to last shared character between key and path
|
||||
while path_reader.has_next? && key_reader.has_next?
|
||||
break if path_reader.current_char != key_reader.current_char
|
||||
|
||||
path_reader.next_char
|
||||
key_reader.next_char
|
||||
end
|
||||
|
||||
# determine split point difference between path and key
|
||||
# compare if path is larger than key
|
||||
if path_reader.pos == 0 ||
|
||||
(path_reader.pos < path.size && path_reader.pos >= node.key.size)
|
||||
# determine if a child of this node contains the remaining part
|
||||
# of the path
|
||||
added = false
|
||||
|
||||
new_key = path_reader.string.byte_slice(path_reader.pos)
|
||||
node.children.each do |child|
|
||||
# compare first character
|
||||
next unless child.key[0]? == new_key[0]?
|
||||
|
||||
# when found, add to this child
|
||||
added = true
|
||||
add new_key, payload, child
|
||||
break
|
||||
end
|
||||
|
||||
# if no existing child shared part of the key, add a new one
|
||||
unless added
|
||||
node.children << Node.new(new_key, payload)
|
||||
end
|
||||
|
||||
# adjust priorities
|
||||
node.sort!
|
||||
elsif path_reader.pos == path.size && path_reader.pos == node.key.size
|
||||
# determine if path matches key and potentially be a duplicate
|
||||
# and raise if is the case
|
||||
|
||||
if node.payload?
|
||||
raise DuplicateError.new(path)
|
||||
else
|
||||
# assign payload since this is an empty node
|
||||
node.payload = payload
|
||||
end
|
||||
elsif path_reader.pos > 0 && path_reader.pos < node.key.size
|
||||
# determine if current node key needs to be split to accomodate new
|
||||
# children nodes
|
||||
|
||||
# build new node with partial key and adjust existing one
|
||||
new_key = node.key.byte_slice(path_reader.pos)
|
||||
swap_payload = node.payload? ? node.payload : nil
|
||||
|
||||
new_node = Node.new(new_key, swap_payload)
|
||||
new_node.children.replace(node.children)
|
||||
|
||||
# clear payload and children (this is no longer and endpoint)
|
||||
node.payload = nil
|
||||
node.children.clear
|
||||
|
||||
# adjust existing node key to new partial one
|
||||
node.key = path_reader.string.byte_slice(0, path_reader.pos)
|
||||
node.children << new_node
|
||||
node.sort!
|
||||
|
||||
# determine if path still continues
|
||||
if path_reader.pos < path.size
|
||||
new_key = path.byte_slice(path_reader.pos)
|
||||
node.children << Node.new(new_key, payload)
|
||||
node.sort!
|
||||
|
||||
# clear payload (no endpoint)
|
||||
node.payload = nil
|
||||
else
|
||||
# this is an endpoint, set payload
|
||||
node.payload = payload
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a `Result` instance after walking the tree looking up for
|
||||
# *path*
|
||||
#
|
||||
# It will start walking the tree from the root node until a matching
|
||||
# endpoint is found (or not).
|
||||
#
|
||||
# ```
|
||||
# tree = Tree.new
|
||||
# tree.add "/about", :about
|
||||
#
|
||||
# result = tree.find "/products"
|
||||
# result.found?
|
||||
# # => false
|
||||
#
|
||||
# result = tree.find "/about"
|
||||
# result.found?
|
||||
# # => true
|
||||
#
|
||||
# result.payload
|
||||
# # => :about
|
||||
# ```
|
||||
def find(path : String)
|
||||
result = Result.new
|
||||
root = @root
|
||||
|
||||
# walk the tree from root (first time)
|
||||
find path, result, root, first: true
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
private def find(path : String, result : Result, node : Node, first = false)
|
||||
# special consideration when comparing the first node vs. others
|
||||
# in case of node key and path being the same, return the node
|
||||
# instead of walking character by character
|
||||
if first && (path.size == node.key.size && path == node.key) && node.payload?
|
||||
result.use node
|
||||
return
|
||||
end
|
||||
|
||||
key_reader = Char::Reader.new(node.key)
|
||||
path_reader = Char::Reader.new(path)
|
||||
|
||||
# walk both path and key while both have characters and they continue
|
||||
# to match. Consider as special cases named parameters and catch all
|
||||
# rules.
|
||||
while key_reader.has_next? && path_reader.has_next? &&
|
||||
(key_reader.current_char == '*' ||
|
||||
key_reader.current_char == ':' ||
|
||||
path_reader.current_char == key_reader.current_char)
|
||||
case key_reader.current_char
|
||||
when '*'
|
||||
# deal with catch all (globbing) parameter
|
||||
# extract parameter name from key (exclude *) and value from path
|
||||
name = key_reader.string.byte_slice(key_reader.pos + 1)
|
||||
value = path_reader.string.byte_slice(path_reader.pos)
|
||||
|
||||
# add this to result
|
||||
result.params[name] = value
|
||||
|
||||
result.use node
|
||||
return
|
||||
when ':'
|
||||
# deal with named parameter
|
||||
# extract parameter name from key (from : until / or EOL) and
|
||||
# value from path (same rules as key)
|
||||
key_size = _detect_param_size(key_reader)
|
||||
path_size = _detect_param_size(path_reader)
|
||||
|
||||
# obtain key and value using calculated sizes
|
||||
name = key_reader.string.byte_slice(key_reader.pos + 1, key_size)
|
||||
value = path_reader.string.byte_slice(path_reader.pos, path_size)
|
||||
|
||||
# add this information to result
|
||||
result.params[name] = value
|
||||
|
||||
# advance readers positions
|
||||
key_reader.pos += key_size
|
||||
path_reader.pos += path_size
|
||||
else
|
||||
# move to the next character
|
||||
key_reader.next_char
|
||||
path_reader.next_char
|
||||
end
|
||||
end
|
||||
|
||||
# check if we reached the end of the path & key
|
||||
if !path_reader.has_next? && !key_reader.has_next?
|
||||
# check endpoint
|
||||
if node.payload?
|
||||
result.use node
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# still path to walk, check for possible trailing slash or children
|
||||
# nodes
|
||||
if path_reader.has_next?
|
||||
# using trailing slash?
|
||||
if node.key.size > 0 &&
|
||||
path_reader.pos + 1 == path.size &&
|
||||
path_reader.current_char == '/'
|
||||
result.use node
|
||||
return
|
||||
end
|
||||
|
||||
# not found in current node, check inside children nodes
|
||||
new_path = path_reader.string.byte_slice(path_reader.pos)
|
||||
node.children.each do |child|
|
||||
# check if child first character matches the new path
|
||||
if child.key[0]? == new_path[0]? ||
|
||||
child.key[0]? == '*' || child.key[0]? == ':'
|
||||
# consider this node for key but don't use payload
|
||||
result.use node, payload: false
|
||||
|
||||
find new_path, result, child
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# key still contains characters to walk
|
||||
if key_reader.has_next?
|
||||
# determine if there is just a trailing slash?
|
||||
if key_reader.pos + 1 == node.key.size &&
|
||||
key_reader.current_char == '/'
|
||||
result.use node
|
||||
return
|
||||
end
|
||||
|
||||
# check if remaining part is catch all
|
||||
if key_reader.pos < node.key.size &&
|
||||
key_reader.current_char == '/' &&
|
||||
key_reader.peek_next_char == '*'
|
||||
# skip '*'
|
||||
key_reader.next_char
|
||||
|
||||
# deal with catch all, but since there is nothing in the path
|
||||
# return parameter as empty
|
||||
name = key_reader.string.byte_slice(key_reader.pos + 1)
|
||||
|
||||
result.params[name] = ""
|
||||
|
||||
result.use node
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
private def _detect_param_size(reader)
|
||||
# save old position
|
||||
old_pos = reader.pos
|
||||
|
||||
# move forward until '/' or EOL is detected
|
||||
while reader.has_next?
|
||||
break if reader.current_char == '/'
|
||||
|
||||
reader.next_char
|
||||
end
|
||||
|
||||
# calculate the size
|
||||
count = reader.pos - old_pos
|
||||
|
||||
# restore old position
|
||||
reader.pos = old_pos
|
||||
|
||||
count
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue