hierarchy 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.gitignore +25 -0
- data/.rspec +1 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +100 -0
- data/LICENSE +20 -0
- data/README.textile +96 -0
- data/Rakefile +37 -0
- data/VERSION +1 -0
- data/hierarchy.gemspec +67 -0
- data/lib/hierarchy/index_path.rb +59 -0
- data/lib/hierarchy/node.rb +53 -0
- data/lib/hierarchy.rb +166 -0
- data/lib/hierarchy_generator.rb +22 -0
- data/spec/hierarchy_spec.rb +110 -0
- data/spec/index_path_spec.rb +31 -0
- data/spec/spec_helper.rb +26 -0
- data/templates/add_ltree_type.rb +15 -0
- metadata +112 -0
data/.document
ADDED
data/.gitignore
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
## MAC OS
|
2
|
+
.DS_Store
|
3
|
+
|
4
|
+
## TEXTMATE
|
5
|
+
*.tmproj
|
6
|
+
tmtags
|
7
|
+
|
8
|
+
## EMACS
|
9
|
+
*~
|
10
|
+
\#*
|
11
|
+
.\#*
|
12
|
+
|
13
|
+
## VIM
|
14
|
+
*.swp
|
15
|
+
|
16
|
+
## PROJECT::GENERAL
|
17
|
+
coverage
|
18
|
+
rdoc
|
19
|
+
pkg
|
20
|
+
.bundle
|
21
|
+
.rvmrc
|
22
|
+
|
23
|
+
## PROJECT::DOCUMENTATION
|
24
|
+
.yardoc
|
25
|
+
doc
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
-cfs
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
RedCloth (4.2.3)
|
5
|
+
abstract (1.0.0)
|
6
|
+
actionmailer (3.0.1)
|
7
|
+
actionpack (= 3.0.1)
|
8
|
+
mail (~> 2.2.5)
|
9
|
+
actionpack (3.0.1)
|
10
|
+
activemodel (= 3.0.1)
|
11
|
+
activesupport (= 3.0.1)
|
12
|
+
builder (~> 2.1.2)
|
13
|
+
erubis (~> 2.6.6)
|
14
|
+
i18n (~> 0.4.1)
|
15
|
+
rack (~> 1.2.1)
|
16
|
+
rack-mount (~> 0.6.12)
|
17
|
+
rack-test (~> 0.5.4)
|
18
|
+
tzinfo (~> 0.3.23)
|
19
|
+
activemodel (3.0.1)
|
20
|
+
activesupport (= 3.0.1)
|
21
|
+
builder (~> 2.1.2)
|
22
|
+
i18n (~> 0.4.1)
|
23
|
+
activerecord (3.0.1)
|
24
|
+
activemodel (= 3.0.1)
|
25
|
+
activesupport (= 3.0.1)
|
26
|
+
arel (~> 1.0.0)
|
27
|
+
tzinfo (~> 0.3.23)
|
28
|
+
activeresource (3.0.1)
|
29
|
+
activemodel (= 3.0.1)
|
30
|
+
activesupport (= 3.0.1)
|
31
|
+
activesupport (3.0.1)
|
32
|
+
arel (1.0.1)
|
33
|
+
activesupport (~> 3.0.0)
|
34
|
+
builder (2.1.2)
|
35
|
+
diff-lcs (1.1.2)
|
36
|
+
erubis (2.6.6)
|
37
|
+
abstract (>= 1.0.0)
|
38
|
+
gemcutter (0.6.1)
|
39
|
+
git (1.2.5)
|
40
|
+
i18n (0.4.2)
|
41
|
+
jeweler (1.4.0)
|
42
|
+
gemcutter (>= 0.1.0)
|
43
|
+
git (>= 1.2.5)
|
44
|
+
rubyforge (>= 2.0.0)
|
45
|
+
json_pure (1.4.6)
|
46
|
+
mail (2.2.9)
|
47
|
+
activesupport (>= 2.3.6)
|
48
|
+
i18n (~> 0.4.1)
|
49
|
+
mime-types (~> 1.16)
|
50
|
+
treetop (~> 1.4.8)
|
51
|
+
mime-types (1.16)
|
52
|
+
pg (0.9.0)
|
53
|
+
polyglot (0.3.1)
|
54
|
+
rack (1.2.1)
|
55
|
+
rack-mount (0.6.13)
|
56
|
+
rack (>= 1.0.0)
|
57
|
+
rack-test (0.5.6)
|
58
|
+
rack (>= 1.0)
|
59
|
+
rails (3.0.1)
|
60
|
+
actionmailer (= 3.0.1)
|
61
|
+
actionpack (= 3.0.1)
|
62
|
+
activerecord (= 3.0.1)
|
63
|
+
activeresource (= 3.0.1)
|
64
|
+
activesupport (= 3.0.1)
|
65
|
+
bundler (~> 1.0.0)
|
66
|
+
railties (= 3.0.1)
|
67
|
+
railties (3.0.1)
|
68
|
+
actionpack (= 3.0.1)
|
69
|
+
activesupport (= 3.0.1)
|
70
|
+
rake (>= 0.8.4)
|
71
|
+
thor (~> 0.14.0)
|
72
|
+
rake (0.8.7)
|
73
|
+
rspec (2.0.1)
|
74
|
+
rspec-core (~> 2.0.1)
|
75
|
+
rspec-expectations (~> 2.0.1)
|
76
|
+
rspec-mocks (~> 2.0.1)
|
77
|
+
rspec-core (2.0.1)
|
78
|
+
rspec-expectations (2.0.1)
|
79
|
+
diff-lcs (>= 1.1.2)
|
80
|
+
rspec-mocks (2.0.1)
|
81
|
+
rspec-core (~> 2.0.1)
|
82
|
+
rspec-expectations (~> 2.0.1)
|
83
|
+
rubyforge (2.0.4)
|
84
|
+
json_pure (>= 1.1.7)
|
85
|
+
thor (0.14.3)
|
86
|
+
treetop (1.4.8)
|
87
|
+
polyglot (>= 0.3.1)
|
88
|
+
tzinfo (0.3.23)
|
89
|
+
yard (0.6.1)
|
90
|
+
|
91
|
+
PLATFORMS
|
92
|
+
ruby
|
93
|
+
|
94
|
+
DEPENDENCIES
|
95
|
+
RedCloth
|
96
|
+
jeweler
|
97
|
+
pg
|
98
|
+
rails (>= 3.0)
|
99
|
+
rspec
|
100
|
+
yard
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Tim Morgan
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.textile
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
h1. Hierarchy -- Use PostgreSQL @LTREE@ columns in ActiveRecord
|
2
|
+
|
3
|
+
| *Author* | Tim Morgan |
|
4
|
+
| *Version* | 1.0 (Oct 30, 2010) |
|
5
|
+
| *License* | Released under the MIT license. |
|
6
|
+
|
7
|
+
h2. About
|
8
|
+
|
9
|
+
The @LTREE@ column type is a PostgreSQL-specific type (installation script is
|
10
|
+
available in the @contrib@ directory in your PostgreSQL installation) for
|
11
|
+
representing hierarchies. It is more efficient than the typical way of
|
12
|
+
accomplishing hierarchal structures in SQL, the @parent_id@ column (or similar).
|
13
|
+
|
14
|
+
This gem lets you use an @LTREE@-utilizing hierarchy in ActiveRecord. Including
|
15
|
+
this gem in your project gets you a) monkey-patches* to Arel to support @LTREE@
|
16
|
+
columns, and b) a module you can include in your models, providing an abundance
|
17
|
+
of methods to help you navigate and manipulate the hierarchy.
|
18
|
+
|
19
|
+
*Though I don't know for sure, I suspect when Arel 2.0 is supported in Rails, I
|
20
|
+
will be able to remove this monkey-patching ugliness.
|
21
|
+
|
22
|
+
h2. Installation
|
23
|
+
|
24
|
+
*Important Note:* This gem requires Ruby 1.9 and Rails 3.0.
|
25
|
+
|
26
|
+
Firstly, add the gem to your Rails project's @Gemfile@:
|
27
|
+
|
28
|
+
<pre><code>
|
29
|
+
gem 'hierarchy'
|
30
|
+
</code></pre>
|
31
|
+
|
32
|
+
Then, run the generator to install the migration:
|
33
|
+
|
34
|
+
<pre><code>
|
35
|
+
rails generate hierarchy
|
36
|
+
</code></pre>
|
37
|
+
|
38
|
+
Note that *this migration must precede any tables using @LTREEs@*, so reorder
|
39
|
+
the migration if you have to.
|
40
|
+
|
41
|
+
h2. Usage
|
42
|
+
|
43
|
+
Because this gem was hastily extracted from a personal project, it's a little
|
44
|
+
constraining in how it can be used. (Sorry.) Currently the gem requires that
|
45
|
+
your table schema have a column named @path@ of type @LTREE@, defined as in the
|
46
|
+
example below:
|
47
|
+
|
48
|
+
<pre><code>
|
49
|
+
path LTREE NOT NULL DEFAULT ''
|
50
|
+
</code></pre>
|
51
|
+
|
52
|
+
Once you've got that column in your model, feel free to include the @Hierarchy@
|
53
|
+
module:
|
54
|
+
|
55
|
+
<pre><code>
|
56
|
+
class Person < ActiveRecord::Base
|
57
|
+
include Hierarchy
|
58
|
+
end
|
59
|
+
</code></pre>
|
60
|
+
|
61
|
+
You can now define hierarchy by setting a model's @parent@, like so:
|
62
|
+
|
63
|
+
<pre><code>
|
64
|
+
person.parent = mother #=> Sets the `path` column appropriately
|
65
|
+
</code></pre>
|
66
|
+
|
67
|
+
You also have access to a wealth of ways to traverse the hierarchy:
|
68
|
+
|
69
|
+
<pre><code>
|
70
|
+
person.children.where(gender: :male)
|
71
|
+
person.top_level?
|
72
|
+
Person.treeified #=> returns a traversable tree of all people
|
73
|
+
</code></pre>
|
74
|
+
|
75
|
+
For more information on what you can do, see the {Hierarchy} module
|
76
|
+
documentation.
|
77
|
+
|
78
|
+
h2. Development
|
79
|
+
|
80
|
+
If you wish to develop for Hierarchy, the first thing you will want to do is get
|
81
|
+
specs up and running. This requires a call to @bundle install@ (obviously) and
|
82
|
+
setting up your test database.
|
83
|
+
|
84
|
+
As you can see in the @spec/spec_helper.rb@ file, the specs require that a
|
85
|
+
PostgreSQL database named @hierarchy_test@ exist and be owned by a
|
86
|
+
@hierarchy_tester@ user. Unfortunately I haven't written a way to configure this
|
87
|
+
(though patches are welcome). So, the following commands should suffice to get
|
88
|
+
you started:
|
89
|
+
|
90
|
+
<pre><code>
|
91
|
+
createuser hierarchy_tester # answer "no" to all prompts
|
92
|
+
createdb -O hierarchy_tester hierarchy_test
|
93
|
+
</code></pre>
|
94
|
+
|
95
|
+
With those steps done you should be able to run @rake spec@ and see the Glorious
|
96
|
+
Green.
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'rake'
|
2
|
+
begin
|
3
|
+
require 'bundler'
|
4
|
+
rescue LoadError
|
5
|
+
puts "Bundler is not installed; install with `gem install bundler`."
|
6
|
+
exit 1
|
7
|
+
end
|
8
|
+
|
9
|
+
Bundler.require :default
|
10
|
+
|
11
|
+
Jeweler::Tasks.new do |gem|
|
12
|
+
gem.name = "hierarchy"
|
13
|
+
gem.summary = %Q{Use PostgreSQL LTREE type with ActiveRecord}
|
14
|
+
gem.description = %Q{Adds ActiveRecord support for hierarchial data structures using PostgreSQL's LTREE column type.}
|
15
|
+
gem.email = "git@timothymorgan.info"
|
16
|
+
gem.homepage = "http://github.com/riscfuture/hierarchy"
|
17
|
+
gem.authors = [ "Tim Morgan" ]
|
18
|
+
gem.required_ruby_version = '>= 1.9'
|
19
|
+
gem.add_dependency 'activerecord', '>= 3.0'
|
20
|
+
gem.add_dependency 'activesupport', '>= 3.0'
|
21
|
+
end
|
22
|
+
Jeweler::GemcutterTasks.new
|
23
|
+
|
24
|
+
require 'rspec/core/rake_task'
|
25
|
+
RSpec::Core::RakeTask.new
|
26
|
+
|
27
|
+
YARD::Rake::YardocTask.new('doc') do |doc|
|
28
|
+
doc.options << "-m" << "textile"
|
29
|
+
doc.options << "--protected"
|
30
|
+
doc.options << "-r" << "README.textile"
|
31
|
+
doc.options << "-o" << "doc"
|
32
|
+
doc.options << "--title" << "Hierarchy Documentation".inspect
|
33
|
+
|
34
|
+
doc.files = [ 'lib/**/*', 'README.textile' ]
|
35
|
+
end
|
36
|
+
|
37
|
+
task(default: :spec)
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
data/hierarchy.gemspec
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{hierarchy}
|
8
|
+
s.version = "1.0.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Tim Morgan"]
|
12
|
+
s.date = %q{2010-10-30}
|
13
|
+
s.description = %q{Adds ActiveRecord support for hierarchial data structures using PostgreSQL's LTREE column type.}
|
14
|
+
s.email = %q{git@timothymorgan.info}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE",
|
17
|
+
"README.textile"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".document",
|
21
|
+
".gitignore",
|
22
|
+
".rspec",
|
23
|
+
"Gemfile",
|
24
|
+
"Gemfile.lock",
|
25
|
+
"LICENSE",
|
26
|
+
"README.textile",
|
27
|
+
"Rakefile",
|
28
|
+
"VERSION",
|
29
|
+
"hierarchy.gemspec",
|
30
|
+
"lib/hierarchy.rb",
|
31
|
+
"lib/hierarchy/index_path.rb",
|
32
|
+
"lib/hierarchy/node.rb",
|
33
|
+
"lib/hierarchy_generator.rb",
|
34
|
+
"spec/hierarchy_spec.rb",
|
35
|
+
"spec/index_path_spec.rb",
|
36
|
+
"spec/spec_helper.rb",
|
37
|
+
"templates/add_ltree_type.rb"
|
38
|
+
]
|
39
|
+
s.homepage = %q{http://github.com/riscfuture/hierarchy}
|
40
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
41
|
+
s.require_paths = ["lib"]
|
42
|
+
s.required_ruby_version = Gem::Requirement.new(">= 1.9")
|
43
|
+
s.rubygems_version = %q{1.3.7}
|
44
|
+
s.summary = %q{Use PostgreSQL LTREE type with ActiveRecord}
|
45
|
+
s.test_files = [
|
46
|
+
"spec/hierarchy_spec.rb",
|
47
|
+
"spec/index_path_spec.rb",
|
48
|
+
"spec/spec_helper.rb"
|
49
|
+
]
|
50
|
+
|
51
|
+
if s.respond_to? :specification_version then
|
52
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
53
|
+
s.specification_version = 3
|
54
|
+
|
55
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
56
|
+
s.add_runtime_dependency(%q<activerecord>, [">= 3.0"])
|
57
|
+
s.add_runtime_dependency(%q<activesupport>, [">= 3.0"])
|
58
|
+
else
|
59
|
+
s.add_dependency(%q<activerecord>, [">= 3.0"])
|
60
|
+
s.add_dependency(%q<activesupport>, [">= 3.0"])
|
61
|
+
end
|
62
|
+
else
|
63
|
+
s.add_dependency(%q<activerecord>, [">= 3.0"])
|
64
|
+
s.add_dependency(%q<activesupport>, [">= 3.0"])
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Hierarchy
|
2
|
+
|
3
|
+
# An array of integers representing an ordered list of IDs. Duck-types an
|
4
|
+
# @Array@ in many ways.
|
5
|
+
|
6
|
+
class IndexPath
|
7
|
+
include Enumerable
|
8
|
+
|
9
|
+
delegate :&, :+, :-, :<<, :==, :[], :[]=, :at, :clear, :collect, :collect!,
|
10
|
+
:concat, :count, :delete_at, :delete_if, :drop, :drop_while, :each,
|
11
|
+
:each_index, :empty?, :eql?, :fetch, :index, :first, :include?,
|
12
|
+
:insert, :join, :keep_if, :last, :length, :map, :map!, :pop, :push,
|
13
|
+
:reject, :reject!, :reverse, :reverse!, :rindex, :select, :select!,
|
14
|
+
:shift, :size, :slice, :slice!, :take, :take_while, :to_a, :to_ary,
|
15
|
+
:unshift, :values_at, :|, to: :indexes
|
16
|
+
|
17
|
+
# @overload initialize(id, ...)
|
18
|
+
# Creates an index path from a list of integer IDs.
|
19
|
+
# @param [Fixnum] id An integer ID.
|
20
|
+
# @return [IndexPath] A new instance.
|
21
|
+
# @raise [ArgumentError] If an invalid ID is given.
|
22
|
+
|
23
|
+
def initialize(*indexes)
|
24
|
+
raise ArgumentError, "IndexPath indexes must be integers" unless indexes.all? { |index| index.kind_of?(Fixnum) }
|
25
|
+
@indexes = indexes
|
26
|
+
end
|
27
|
+
|
28
|
+
# Creates an index path from a PostgreSQL @LTREE@ column.
|
29
|
+
#
|
30
|
+
# @param [String] string An @LTREE@ column value, such as "1.10.22".
|
31
|
+
# @return [IndexPath] A corresponding index path.
|
32
|
+
|
33
|
+
def self.from_ltree(string)
|
34
|
+
new(*(string.split('.').map(&:to_i)))
|
35
|
+
end
|
36
|
+
|
37
|
+
# Defines a natural ordering of index paths. Paths with lower IDs at the same
|
38
|
+
# index level will come before those with higher IDs at that index level.
|
39
|
+
# Lower IDs at shallower index levels come before lower IDs at deeper index
|
40
|
+
# levels.
|
41
|
+
#
|
42
|
+
# @param [IndexPath] other An index path to compare.
|
43
|
+
# @return [-1, 0, 1] -1 if this index path comes before the other one, 0 if
|
44
|
+
# they are identical, or 1 if this index path comes after the other one.
|
45
|
+
# @raise [ArgumentError] If something other than an index path is given.
|
46
|
+
|
47
|
+
def <=>(other)
|
48
|
+
raise ArgumentError, "Can't compare IndexPath and #{other.class.to_s}" unless other.kind_of?(IndexPath)
|
49
|
+
indexes <=> other.send(:indexes)
|
50
|
+
end
|
51
|
+
|
52
|
+
# @private
|
53
|
+
def inspect() "#<#{self.class.to_s} #{@indexes.inspect}>" end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def indexes() @indexes end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Hierarchy
|
2
|
+
|
3
|
+
# A node in a tree structure. A node can have zero or more {#children}, and
|
4
|
+
# has a reverse link back to its parent.
|
5
|
+
|
6
|
+
class Node
|
7
|
+
# @return [Array<Node>] This node's children.
|
8
|
+
attr_reader :children
|
9
|
+
|
10
|
+
# @return [Node, nil] This node's parent, or @nil@ if it is a root node.
|
11
|
+
attr_reader :parent
|
12
|
+
|
13
|
+
# @return The object this node contains.
|
14
|
+
attr_accessor :content
|
15
|
+
|
16
|
+
# Creates a new root node with no children.
|
17
|
+
#
|
18
|
+
# @param content The content the node will contain.
|
19
|
+
|
20
|
+
def initialize(content)
|
21
|
+
@children = []
|
22
|
+
@content = content
|
23
|
+
end
|
24
|
+
|
25
|
+
# Adds a node as a child of this one. Sets the {#parent} of the given node.
|
26
|
+
#
|
27
|
+
# @param [Node] child The node to add as a child of this node.
|
28
|
+
|
29
|
+
def <<(child)
|
30
|
+
children << child
|
31
|
+
child.instance_variable_set :@parent, self
|
32
|
+
end
|
33
|
+
|
34
|
+
# Performs a depth-first traversal using this as the root node of the tree.
|
35
|
+
# @yield [node] Each node in depth-first order.
|
36
|
+
# @yieldparam [Node] node A child node.
|
37
|
+
|
38
|
+
def traverse(&block)
|
39
|
+
children.each { |child| child.traverse &block }
|
40
|
+
block[self]
|
41
|
+
end
|
42
|
+
|
43
|
+
# @private
|
44
|
+
def inspect
|
45
|
+
str = "#<#{self.class.to_s} #{content.inspect}"
|
46
|
+
unless children.empty?
|
47
|
+
str << ": [ #{children.map(&:inspect).join(', ')} ]"
|
48
|
+
end
|
49
|
+
str << ">"
|
50
|
+
str
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
data/lib/hierarchy.rb
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
# @private
|
2
|
+
module Arel
|
3
|
+
# @private
|
4
|
+
module Sql
|
5
|
+
# @private
|
6
|
+
module Attributes
|
7
|
+
# @private
|
8
|
+
def self.for_with_psql(column)
|
9
|
+
case column.sql_type
|
10
|
+
when 'ltree' then String
|
11
|
+
else for_without_psql(column)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
unless singleton_class.method_defined?(:for_without_psql)
|
15
|
+
singleton_class.send :alias_method, :for_without_psql, :for
|
16
|
+
singleton_class.send :alias_method, :for, :for_with_psql
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
require 'hierarchy_generator'
|
23
|
+
require 'hierarchy/index_path'
|
24
|
+
require 'hierarchy/node'
|
25
|
+
|
26
|
+
# Adds a tree structure to a model. This is very similar to @acts_as_nested_set@
|
27
|
+
# but uses the PostgreSQL-specific @ltree@ feature for schema storage.
|
28
|
+
#
|
29
|
+
# Your model must have a @path@ field of type @ltree@. This field will be a
|
30
|
+
# period-delimited list of IDs of records above this one in the hierarchy. In
|
31
|
+
# addition, you should also consider the following indexes:
|
32
|
+
#
|
33
|
+
# <pre><code>
|
34
|
+
# CREATE INDEX index1 ON table USING gist(path)
|
35
|
+
# CREATE INDEX index2 ON table USING btree(path)
|
36
|
+
# </code></pre>
|
37
|
+
#
|
38
|
+
# replacing @table@ with your table and @index1@/@index2@ with appropriate names
|
39
|
+
# for these indexes.
|
40
|
+
#
|
41
|
+
# @example
|
42
|
+
# class MyModel < ActiveRecord::Base
|
43
|
+
# include Hierarchy
|
44
|
+
# end
|
45
|
+
|
46
|
+
module Hierarchy
|
47
|
+
extend ActiveSupport::Concern
|
48
|
+
|
49
|
+
# @private
|
50
|
+
included do |base|
|
51
|
+
base.extend ActiveSupport::Memoizable
|
52
|
+
base.memoize :index_path, :ancestors
|
53
|
+
|
54
|
+
base.scope :parent_of, ->(obj) { obj.top_level? ? base.where('false') : base.where(id: obj.index_path.last) }
|
55
|
+
base.scope :children_of, ->(obj) { base.where(path: obj.my_path) }
|
56
|
+
base.scope :ancestors_of, ->(obj) { base.where(id: obj.index_path.to_a) }
|
57
|
+
base.scope :descendants_of, ->(obj) { base.where([ "path <@ ?", obj.my_path ]) }
|
58
|
+
base.scope :siblings_of, ->(obj) { base.where(path: obj.path) }
|
59
|
+
base.scope :priority_order, base.order("NLEVEL(path) ASC")
|
60
|
+
|
61
|
+
base.before_save { |obj| obj.path ||= '' }
|
62
|
+
end
|
63
|
+
|
64
|
+
module ClassMethods
|
65
|
+
|
66
|
+
# @overload treeified
|
67
|
+
# @return [Hash<ActiveRecord::Base, Hash<...>>] All models organized
|
68
|
+
# into a tree structure. Returns a hash where each key is a tree root,
|
69
|
+
# and the values are themselves hashes whose keys are the children of the
|
70
|
+
# respective model.
|
71
|
+
|
72
|
+
def treeified(root=nil, objects=nil)
|
73
|
+
path = root ? root.content.my_path : ''
|
74
|
+
root ||= Node.new(nil)
|
75
|
+
objects ||= order('id ASC').all.sort_by { |o| [ o.index_path, o.id ] }
|
76
|
+
|
77
|
+
while objects.first and objects.first.path == path
|
78
|
+
child = objects.shift
|
79
|
+
root << Node.new(child)
|
80
|
+
end
|
81
|
+
|
82
|
+
root.children.each do |child|
|
83
|
+
treeified child, objects
|
84
|
+
end
|
85
|
+
|
86
|
+
return root
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Methods added to instances of the class this module is included into.
|
91
|
+
|
92
|
+
module InstanceMethods
|
93
|
+
# Sets the object above this one in the hierarchy.
|
94
|
+
#
|
95
|
+
# @param [ActiveRecord::Base] parent The parent object.
|
96
|
+
# @raise [ArgumentError] If @parent@ is an unsaved record with no primary key.
|
97
|
+
|
98
|
+
def parent=(parent)
|
99
|
+
raise ArgumentError, "Parent cannot be a new record" if parent.try(:new_record?)
|
100
|
+
self.path = parent.try(:my_path)
|
101
|
+
end
|
102
|
+
|
103
|
+
# Returns an array of ancestors above this object. Note that a) this array
|
104
|
+
# is ordered with the most senior ancestor at the beginning of the list, and
|
105
|
+
# b) this is an _array_, not a _relation_. For that reason, you can pass
|
106
|
+
# any additional scope options to the method.
|
107
|
+
#
|
108
|
+
# @param [Hash] options Additional finder options.
|
109
|
+
# @return [Array] The objects above this one in the hierarchy.
|
110
|
+
|
111
|
+
def ancestors(options={})
|
112
|
+
objects = self.class.ancestors_of(self).scoped(options).group_by(&:id)
|
113
|
+
index_path.map { |id| objects[id].first }
|
114
|
+
end
|
115
|
+
|
116
|
+
# @return [ActiveRecord::Relation] The objects below this one in the
|
117
|
+
# hierarchy.
|
118
|
+
|
119
|
+
def descendants
|
120
|
+
self.class.descendants_of self
|
121
|
+
end
|
122
|
+
|
123
|
+
# @return [ActiveRecord::Base] The object directly above this one in the
|
124
|
+
# hierarchy.
|
125
|
+
|
126
|
+
def parent
|
127
|
+
top_level? ? nil : self.class.parent_of(self).first
|
128
|
+
end
|
129
|
+
|
130
|
+
# @return [ActiveRecord::Relation] The objects directly below this one
|
131
|
+
# in the hierarchy.
|
132
|
+
|
133
|
+
def children
|
134
|
+
self.class.children_of self
|
135
|
+
end
|
136
|
+
|
137
|
+
# @return [Array] The objects at the same hierarchical level of this one.
|
138
|
+
|
139
|
+
def siblings
|
140
|
+
self.class.siblings_of(self) - [ self ]
|
141
|
+
end
|
142
|
+
|
143
|
+
# @return [true, false] Whether or not this object has no parents.
|
144
|
+
|
145
|
+
def top_level?
|
146
|
+
path == ''
|
147
|
+
end
|
148
|
+
|
149
|
+
# @return [true, false] Whether or not this object has no children. Makes a
|
150
|
+
# database call.
|
151
|
+
|
152
|
+
def bottom_level?
|
153
|
+
children.empty?
|
154
|
+
end
|
155
|
+
|
156
|
+
# @private
|
157
|
+
def my_path
|
158
|
+
path.blank? ? id.to_s : "#{path}.#{id}"
|
159
|
+
end
|
160
|
+
|
161
|
+
# @private
|
162
|
+
def index_path
|
163
|
+
IndexPath.from_ltree path
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators'
|
3
|
+
require 'rails/generators/migration'
|
4
|
+
|
5
|
+
# @private
|
6
|
+
class HierarchyGenerator < Rails::Generators::Base
|
7
|
+
include Rails::Generators::Migration
|
8
|
+
|
9
|
+
source_root "#{File.dirname __FILE__}/../templates"
|
10
|
+
|
11
|
+
def self.next_migration_number(dirname)
|
12
|
+
if ActiveRecord::Base.timestamped_migrations then
|
13
|
+
Time.now.utc.strftime "%Y%m%d%H%M%S"
|
14
|
+
else
|
15
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def copy_files
|
20
|
+
migration_template "add_ltree_type.rb", "db/migrate/add_ltree_type.rb"
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe Hierarchy do
|
4
|
+
before :each do
|
5
|
+
@objects = Hash.new
|
6
|
+
2.times { |i| @objects[:"grandparent_#{i + 1}"] = Model.create! }
|
7
|
+
2.times do |gp_num|
|
8
|
+
2.times { |i| @objects[:"parent_#{gp_num + 1}_#{i + 1}"] = Model.create!(parent: @objects[:"grandparent_#{gp_num + 1}"]) }
|
9
|
+
end
|
10
|
+
2.times do |gp_num|
|
11
|
+
2.times do |p_num|
|
12
|
+
2.times { |i| @objects[:"child_#{gp_num + 1}_#{p_num + 1}_#{i + 1}"] = Model.create!(parent: @objects[:"parent_#{gp_num + 1}_#{p_num + 1}"]) }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe ".parent_of" do
|
18
|
+
it "should return the parent object" do
|
19
|
+
Model.parent_of(@objects[:parent_1_1]).should == [ @objects[:grandparent_1] ]
|
20
|
+
Model.parent_of(@objects[:parent_2_2]).should == [ @objects[:grandparent_2] ]
|
21
|
+
end
|
22
|
+
|
23
|
+
it "should return an empty relation for top-level objects" do
|
24
|
+
Model.parent_of(@objects[:grandparent_1]).should be_empty
|
25
|
+
Model.parent_of(@objects[:grandparent_2]).should be_empty
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe ".children_of" do
|
30
|
+
it "should return the direct children of an object" do
|
31
|
+
Model.children_of(@objects[:grandparent_1]).should == [ :parent_1_1, :parent_1_2 ].map { |name| @objects[name] }
|
32
|
+
Model.children_of(@objects[:parent_2_1]).should == [ :child_2_1_1, :child_2_1_2 ].map { |name| @objects[name] }
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should return an empty relation for leaf objects" do
|
36
|
+
Model.children_of(@objects[:child_1_1_1]).should be_empty
|
37
|
+
Model.children_of(@objects[:child_2_2_2]).should be_empty
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe ".ancestors_of" do
|
42
|
+
it "should return all ancestors of an object" do
|
43
|
+
Model.ancestors_of(@objects[:child_1_2_1]).should == [ :grandparent_1, :parent_1_2 ].map { |name| @objects[name] }
|
44
|
+
Model.ancestors_of(@objects[:parent_2_1]).should == [ @objects[:grandparent_2] ]
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should return an empty relation for top-level objects" do
|
48
|
+
Model.ancestors_of(@objects[:grandparent_1]).should be_empty
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe ".descendants_of" do
|
53
|
+
it "should return all descendants of an object" do
|
54
|
+
Model.descendants_of(@objects[:grandparent_2]).should == [ :parent_2_1, :parent_2_2, :child_2_1_1, :child_2_1_2, :child_2_2_1, :child_2_2_2 ].map { |name| @objects[name] }
|
55
|
+
Model.descendants_of(@objects[:parent_2_1]).should == [ :child_2_1_1, :child_2_1_2 ].map { |name| @objects[name] }
|
56
|
+
end
|
57
|
+
|
58
|
+
it "should return an empty relation for leaf objects" do
|
59
|
+
Model.children_of(@objects[:child_1_1_1]).should be_empty
|
60
|
+
Model.children_of(@objects[:child_2_2_2]).should be_empty
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe ".siblings_of" do
|
65
|
+
it "should return all sibling objects" do
|
66
|
+
Model.siblings_of(@objects[:parent_1_1]).should == [ :parent_1_1, :parent_1_2 ].map { |name| @objects[name] }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe "#parent=" do
|
71
|
+
it "should raise an error if parent is unsaved" do
|
72
|
+
expect { Model.create!.parent = Model.new }.to raise_error(ArgumentError)
|
73
|
+
end
|
74
|
+
|
75
|
+
it "should set the path ltree appropriately" do
|
76
|
+
ggp = Model.create!
|
77
|
+
gp = Model.create!(path: ggp.id.to_s)
|
78
|
+
pa = Model.create!(path: "#{ggp.id}.#{gp.id}")
|
79
|
+
path = [ ggp.id, gp.id, pa.id ].join('.')
|
80
|
+
|
81
|
+
object = Model.new
|
82
|
+
parent = Model.create!(path: path)
|
83
|
+
object.parent = parent
|
84
|
+
object.save!
|
85
|
+
object.path.should eql("#{path}.#{parent.id}")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe "#top_level?" do
|
90
|
+
it "should return true for a top-level object" do
|
91
|
+
Model.create!.should be_top_level
|
92
|
+
end
|
93
|
+
|
94
|
+
it "should return false for an object with a parent" do
|
95
|
+
parent = Model.create!
|
96
|
+
Model.create!(parent: parent).should_not be_top_level
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
describe "#bottom_level?" do
|
101
|
+
it "should return true for an object with no children" do
|
102
|
+
Model.create!.should be_bottom_level
|
103
|
+
end
|
104
|
+
it "should return false for an object with children" do
|
105
|
+
parent = Model.create!
|
106
|
+
Model.create!(parent: parent)
|
107
|
+
parent.should_not be_bottom_level
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe Hierarchy::IndexPath do
|
4
|
+
describe ".<=>" do
|
5
|
+
def compare(ip1, ip2)
|
6
|
+
Hierarchy::IndexPath.new(*ip1) <=> Hierarchy::IndexPath.new(*ip2)
|
7
|
+
end
|
8
|
+
|
9
|
+
it "should return -1 if this index path comes before the given index path" do
|
10
|
+
# all except last digit equal
|
11
|
+
compare([ 1,2,3 ], [ 1,2,4 ]).should eql(-1)
|
12
|
+
# unequal in higher precedence
|
13
|
+
compare([ 1,2,3 ], [ 1,3,2 ]).should eql(-1)
|
14
|
+
# other is longer
|
15
|
+
compare([ 1,2,3 ], [ 1,2,3,4 ]).should eql(-1)
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should return 1 if this index path comes after the given index path" do
|
19
|
+
# all except last digit equal
|
20
|
+
compare([ 1,2,4 ], [ 1,2,3 ]).should eql(1)
|
21
|
+
# unequal in higher precedence
|
22
|
+
compare([ 1,3,2 ], [ 1,2,3 ]).should eql(1)
|
23
|
+
# this is longer
|
24
|
+
compare([ 1,2,3,4 ], [ 1,2,3, ]).should eql(1)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should return 0 if the index paths are equal" do
|
28
|
+
compare([ 1,2,3 ], [ 1,2,3 ]).should eql(0)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
Bundler.require :default, :test
|
2
|
+
require 'active_support'
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
6
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
7
|
+
|
8
|
+
require 'hierarchy'
|
9
|
+
|
10
|
+
ActiveRecord::Base.establish_connection(
|
11
|
+
adapter: 'postgresql',
|
12
|
+
database: 'hierarchy_test',
|
13
|
+
username: 'hierarchy_tester'
|
14
|
+
)
|
15
|
+
system "psql -f `pg_config --sharedir`/contrib/ltree.sql hierarchy_test &>/dev/null"
|
16
|
+
|
17
|
+
class Model < ActiveRecord::Base
|
18
|
+
include Hierarchy
|
19
|
+
end
|
20
|
+
|
21
|
+
RSpec.configure do |config|
|
22
|
+
config.before(:each) do
|
23
|
+
Model.connection.execute "DROP TABLE IF EXISTS models"
|
24
|
+
Model.connection.execute "CREATE TABLE models (id SERIAL PRIMARY KEY, path LTREE NOT NULL DEFAULT '')"
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class AddLtreeType < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
cmd = "psql -f `pg_config --sharedir`/contrib/ltree.sql #{ActiveRecord::Base.connection.instance_variable_get(:@config)[:database]}"
|
4
|
+
puts cmd
|
5
|
+
result = system(cmd)
|
6
|
+
raise "Bad exit" unless result
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.down
|
10
|
+
cmd = "psql -f `pg_config --sharedir`/contrib/uninstall_ltree.sql #{ActiveRecord::Base.connection.instance_variable_get(:@config)[:database]}"
|
11
|
+
puts cmd
|
12
|
+
result = system(cmd)
|
13
|
+
raise "Bad exit" unless result
|
14
|
+
end
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: hierarchy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 1
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
version: 1.0.0
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Tim Morgan
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2010-10-30 00:00:00 -07:00
|
18
|
+
default_executable:
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: activerecord
|
22
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
23
|
+
none: false
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
segments:
|
28
|
+
- 3
|
29
|
+
- 0
|
30
|
+
version: "3.0"
|
31
|
+
type: :runtime
|
32
|
+
prerelease: false
|
33
|
+
version_requirements: *id001
|
34
|
+
- !ruby/object:Gem::Dependency
|
35
|
+
name: activesupport
|
36
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
37
|
+
none: false
|
38
|
+
requirements:
|
39
|
+
- - ">="
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
segments:
|
42
|
+
- 3
|
43
|
+
- 0
|
44
|
+
version: "3.0"
|
45
|
+
type: :runtime
|
46
|
+
prerelease: false
|
47
|
+
version_requirements: *id002
|
48
|
+
description: Adds ActiveRecord support for hierarchial data structures using PostgreSQL's LTREE column type.
|
49
|
+
email: git@timothymorgan.info
|
50
|
+
executables: []
|
51
|
+
|
52
|
+
extensions: []
|
53
|
+
|
54
|
+
extra_rdoc_files:
|
55
|
+
- LICENSE
|
56
|
+
- README.textile
|
57
|
+
files:
|
58
|
+
- .document
|
59
|
+
- .gitignore
|
60
|
+
- .rspec
|
61
|
+
- Gemfile
|
62
|
+
- Gemfile.lock
|
63
|
+
- LICENSE
|
64
|
+
- README.textile
|
65
|
+
- Rakefile
|
66
|
+
- VERSION
|
67
|
+
- hierarchy.gemspec
|
68
|
+
- lib/hierarchy.rb
|
69
|
+
- lib/hierarchy/index_path.rb
|
70
|
+
- lib/hierarchy/node.rb
|
71
|
+
- lib/hierarchy_generator.rb
|
72
|
+
- spec/hierarchy_spec.rb
|
73
|
+
- spec/index_path_spec.rb
|
74
|
+
- spec/spec_helper.rb
|
75
|
+
- templates/add_ltree_type.rb
|
76
|
+
has_rdoc: true
|
77
|
+
homepage: http://github.com/riscfuture/hierarchy
|
78
|
+
licenses: []
|
79
|
+
|
80
|
+
post_install_message:
|
81
|
+
rdoc_options:
|
82
|
+
- --charset=UTF-8
|
83
|
+
require_paths:
|
84
|
+
- lib
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
86
|
+
none: false
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
segments:
|
91
|
+
- 1
|
92
|
+
- 9
|
93
|
+
version: "1.9"
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
95
|
+
none: false
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
segments:
|
100
|
+
- 0
|
101
|
+
version: "0"
|
102
|
+
requirements: []
|
103
|
+
|
104
|
+
rubyforge_project:
|
105
|
+
rubygems_version: 1.3.7
|
106
|
+
signing_key:
|
107
|
+
specification_version: 3
|
108
|
+
summary: Use PostgreSQL LTREE type with ActiveRecord
|
109
|
+
test_files:
|
110
|
+
- spec/hierarchy_spec.rb
|
111
|
+
- spec/index_path_spec.rb
|
112
|
+
- spec/spec_helper.rb
|