hierarchable 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.circleci/config.yml +116 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +58 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +94 -0
- data/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/Rakefile +16 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/hierarchable.gemspec +61 -0
- data/lib/hierarchable/hierarchable.rb +405 -0
- data/lib/hierarchable/version.rb +5 -0
- data/lib/hierarchable.rb +9 -0
- metadata +261 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c927f016abf308f6c0128f2162908afccb89f55b95495b3499ecfa6a6acdec56
|
|
4
|
+
data.tar.gz: 85724a8ec5e256a5251241f7dc5ef8539c2a041bf683834a6ac0081f02efd922
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 28f8b977db00b49eba24c634bcd19381fa631b519c1c371a4b8fdba15b9ac7475ce638a8e0f87c46f28caf1247abc783de1359d999f6f8343bd1d181f48548f4
|
|
7
|
+
data.tar.gz: 7515ea80776d9b60856f91374cedf3d50bfec3f32d572e06bdada94a72a64d64f940ce52b7fd56b1e625ab467885553d6b077d612ad49862f51106e27a6ea4de
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
version: 2.1
|
|
2
|
+
|
|
3
|
+
# ----------------------------------------------------------------------------
|
|
4
|
+
#
|
|
5
|
+
# REUSABLE CUSTOM DEFINITIONS & COMMANDS
|
|
6
|
+
#
|
|
7
|
+
# ----------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
commands:
|
|
10
|
+
attach-dependencies:
|
|
11
|
+
steps:
|
|
12
|
+
- checkout
|
|
13
|
+
- run:
|
|
14
|
+
name: Set up bundler
|
|
15
|
+
command: |
|
|
16
|
+
gem install bundler:2.3.26
|
|
17
|
+
- run:
|
|
18
|
+
name: Bundle Install
|
|
19
|
+
command: |
|
|
20
|
+
bundle config set --local clean 'true'
|
|
21
|
+
bundle config set --local deployment 'true'
|
|
22
|
+
bundle check || bundle install --jobs=4 --retry=3
|
|
23
|
+
- attach_workspace:
|
|
24
|
+
at: .
|
|
25
|
+
|
|
26
|
+
save-results:
|
|
27
|
+
steps:
|
|
28
|
+
- store_test_results:
|
|
29
|
+
path: test/reports
|
|
30
|
+
- store_artifacts:
|
|
31
|
+
name: "Store artifacts: test reports"
|
|
32
|
+
path: reports
|
|
33
|
+
destination: reports
|
|
34
|
+
|
|
35
|
+
# ----------------------------------------------------------------------------
|
|
36
|
+
#
|
|
37
|
+
# JOB DEFINITIONS
|
|
38
|
+
#
|
|
39
|
+
# ----------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
jobs:
|
|
42
|
+
#
|
|
43
|
+
# QUALITY: Make sure that the code is safe, secure, and clean.
|
|
44
|
+
#
|
|
45
|
+
quality:
|
|
46
|
+
resource_class: small
|
|
47
|
+
|
|
48
|
+
docker:
|
|
49
|
+
- image: cimg/ruby:3.1.3
|
|
50
|
+
|
|
51
|
+
steps:
|
|
52
|
+
# --------- SETUP ---------
|
|
53
|
+
|
|
54
|
+
- attach-dependencies
|
|
55
|
+
|
|
56
|
+
# --------- QUALITY CHECKS ---------
|
|
57
|
+
|
|
58
|
+
- run:
|
|
59
|
+
name: Ruby Audit
|
|
60
|
+
command: bundle exec ruby-audit check
|
|
61
|
+
- run:
|
|
62
|
+
name: Bundle Audit
|
|
63
|
+
command: bundle exec bundle-audit check --update
|
|
64
|
+
- run:
|
|
65
|
+
name: Rubocop
|
|
66
|
+
command: bundle exec rubocop
|
|
67
|
+
|
|
68
|
+
# --------- SAVE RESULTS ---------
|
|
69
|
+
|
|
70
|
+
- save-results
|
|
71
|
+
|
|
72
|
+
test:
|
|
73
|
+
resource_class: small
|
|
74
|
+
|
|
75
|
+
docker:
|
|
76
|
+
- image: cimg/ruby:3.1.3
|
|
77
|
+
|
|
78
|
+
steps:
|
|
79
|
+
# --------- SETUP ---------
|
|
80
|
+
|
|
81
|
+
- attach-dependencies
|
|
82
|
+
|
|
83
|
+
# --------- RUN TESTS ---------
|
|
84
|
+
|
|
85
|
+
- run:
|
|
86
|
+
name: Run tests
|
|
87
|
+
command: bundle exec rake test
|
|
88
|
+
|
|
89
|
+
# --------- SAVE RESULTS ---------
|
|
90
|
+
|
|
91
|
+
- save-results
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ----------------------------------------------------------------------------
|
|
95
|
+
#
|
|
96
|
+
# WORKFLOW DEFINITIONS
|
|
97
|
+
#
|
|
98
|
+
# ----------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
workflows:
|
|
101
|
+
version: 2
|
|
102
|
+
commit:
|
|
103
|
+
jobs:
|
|
104
|
+
- quality
|
|
105
|
+
- test
|
|
106
|
+
nightly:
|
|
107
|
+
jobs:
|
|
108
|
+
- quality
|
|
109
|
+
- test
|
|
110
|
+
triggers:
|
|
111
|
+
- schedule:
|
|
112
|
+
cron: "0 2 * * *"
|
|
113
|
+
filters:
|
|
114
|
+
branches:
|
|
115
|
+
only:
|
|
116
|
+
- main
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require:
|
|
2
|
+
- 'rubocop-performance'
|
|
3
|
+
- 'rubocop-rails'
|
|
4
|
+
- 'rubocop-minitest'
|
|
5
|
+
- 'rubocop-rake'
|
|
6
|
+
|
|
7
|
+
AllCops:
|
|
8
|
+
NewCops: enable
|
|
9
|
+
# Don't run rubocop on these files/directories
|
|
10
|
+
Exclude:
|
|
11
|
+
- '**/templates/**/*'
|
|
12
|
+
- '**/vendor/**/*'
|
|
13
|
+
- 'actionpack/lib/action_dispatch/journey/parser.rb'
|
|
14
|
+
- 'lib/templates/**/*'
|
|
15
|
+
- 'db/**/*'
|
|
16
|
+
- 'config/**/*'
|
|
17
|
+
- 'vendor/**/*'
|
|
18
|
+
- 'bin/**/*'
|
|
19
|
+
- 'node_modules/**/*'
|
|
20
|
+
|
|
21
|
+
Layout/LineLength:
|
|
22
|
+
Max: 80
|
|
23
|
+
Exclude:
|
|
24
|
+
- 'lib/hierarchable/hierarchable.rb'
|
|
25
|
+
|
|
26
|
+
Metrics/AbcSize:
|
|
27
|
+
Max: 30
|
|
28
|
+
Exclude:
|
|
29
|
+
- 'test/**/*'
|
|
30
|
+
|
|
31
|
+
Metrics/BlockLength:
|
|
32
|
+
Max: 40
|
|
33
|
+
Exclude:
|
|
34
|
+
- 'hierarchable.gemspec'
|
|
35
|
+
- 'lib/hierarchable/hierarchable.rb'
|
|
36
|
+
- 'test/**/*'
|
|
37
|
+
|
|
38
|
+
Metrics/ClassLength:
|
|
39
|
+
Max: 150
|
|
40
|
+
Exclude:
|
|
41
|
+
- 'test/**/*'
|
|
42
|
+
|
|
43
|
+
Metrics/MethodLength:
|
|
44
|
+
Max: 35
|
|
45
|
+
Exclude:
|
|
46
|
+
- 'test/**/*'
|
|
47
|
+
|
|
48
|
+
Metrics/ModuleLength:
|
|
49
|
+
Max: 100
|
|
50
|
+
Exclude:
|
|
51
|
+
- 'lib/hierarchable/hierarchable.rb'
|
|
52
|
+
|
|
53
|
+
Minitest/MultipleAssertions:
|
|
54
|
+
Max: 10
|
|
55
|
+
|
|
56
|
+
Rails/RefuteMethods:
|
|
57
|
+
Exclude:
|
|
58
|
+
- 'test/**/*'
|
data/.ruby-gemset
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hierarchable
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby-3.1.3
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
hierarchable (0.1.0)
|
|
5
|
+
activerecord (> 4.2.0)
|
|
6
|
+
activesupport (> 4.2.0)
|
|
7
|
+
|
|
8
|
+
GEM
|
|
9
|
+
remote: https://rubygems.org/
|
|
10
|
+
specs:
|
|
11
|
+
activemodel (7.0.4)
|
|
12
|
+
activesupport (= 7.0.4)
|
|
13
|
+
activerecord (7.0.4)
|
|
14
|
+
activemodel (= 7.0.4)
|
|
15
|
+
activesupport (= 7.0.4)
|
|
16
|
+
activesupport (7.0.4)
|
|
17
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
|
18
|
+
i18n (>= 1.6, < 2)
|
|
19
|
+
minitest (>= 5.1)
|
|
20
|
+
tzinfo (~> 2.0)
|
|
21
|
+
ast (2.4.2)
|
|
22
|
+
bundler-audit (0.9.1)
|
|
23
|
+
bundler (>= 1.2.0, < 3)
|
|
24
|
+
thor (~> 1.0)
|
|
25
|
+
concurrent-ruby (1.1.10)
|
|
26
|
+
i18n (1.12.0)
|
|
27
|
+
concurrent-ruby (~> 1.0)
|
|
28
|
+
json (2.6.3)
|
|
29
|
+
minitest (5.16.3)
|
|
30
|
+
parallel (1.22.1)
|
|
31
|
+
parser (3.1.3.0)
|
|
32
|
+
ast (~> 2.4.1)
|
|
33
|
+
rack (3.0.2)
|
|
34
|
+
rainbow (3.1.1)
|
|
35
|
+
rake (13.0.6)
|
|
36
|
+
regexp_parser (2.6.1)
|
|
37
|
+
rexml (3.2.5)
|
|
38
|
+
rubocop (1.40.0)
|
|
39
|
+
json (~> 2.3)
|
|
40
|
+
parallel (~> 1.10)
|
|
41
|
+
parser (>= 3.1.2.1)
|
|
42
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
43
|
+
regexp_parser (>= 1.8, < 3.0)
|
|
44
|
+
rexml (>= 3.2.5, < 4.0)
|
|
45
|
+
rubocop-ast (>= 1.23.0, < 2.0)
|
|
46
|
+
ruby-progressbar (~> 1.7)
|
|
47
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
|
48
|
+
rubocop-ast (1.24.0)
|
|
49
|
+
parser (>= 3.1.1.0)
|
|
50
|
+
rubocop-minitest (0.25.0)
|
|
51
|
+
rubocop (>= 0.90, < 2.0)
|
|
52
|
+
rubocop-performance (1.15.1)
|
|
53
|
+
rubocop (>= 1.7.0, < 2.0)
|
|
54
|
+
rubocop-ast (>= 0.4.0)
|
|
55
|
+
rubocop-rails (2.17.3)
|
|
56
|
+
activesupport (>= 4.2.0)
|
|
57
|
+
rack (>= 1.1)
|
|
58
|
+
rubocop (>= 1.33.0, < 2.0)
|
|
59
|
+
rubocop-rake (0.6.0)
|
|
60
|
+
rubocop (~> 1.0)
|
|
61
|
+
ruby-progressbar (1.11.0)
|
|
62
|
+
ruby_audit (2.1.0)
|
|
63
|
+
bundler-audit (~> 0.9.0)
|
|
64
|
+
sqlite3 (1.5.4-x86_64-darwin)
|
|
65
|
+
sqlite3 (1.5.4-x86_64-linux)
|
|
66
|
+
temping (4.0.0)
|
|
67
|
+
activerecord (>= 5.2, < 7.1)
|
|
68
|
+
activesupport (>= 5.2, < 7.1)
|
|
69
|
+
thor (1.2.1)
|
|
70
|
+
tzinfo (2.0.5)
|
|
71
|
+
concurrent-ruby (~> 1.0)
|
|
72
|
+
unicode-display_width (2.3.0)
|
|
73
|
+
|
|
74
|
+
PLATFORMS
|
|
75
|
+
x86_64-darwin-22
|
|
76
|
+
x86_64-linux
|
|
77
|
+
|
|
78
|
+
DEPENDENCIES
|
|
79
|
+
bundler (~> 2.3)
|
|
80
|
+
bundler-audit
|
|
81
|
+
hierarchable!
|
|
82
|
+
minitest (~> 5.0)
|
|
83
|
+
rake (~> 13.0)
|
|
84
|
+
rubocop (~> 1.21)
|
|
85
|
+
rubocop-minitest
|
|
86
|
+
rubocop-performance
|
|
87
|
+
rubocop-rails
|
|
88
|
+
rubocop-rake
|
|
89
|
+
ruby_audit
|
|
90
|
+
sqlite3
|
|
91
|
+
temping (~> 4.0)
|
|
92
|
+
|
|
93
|
+
BUNDLED WITH
|
|
94
|
+
2.3.26
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 TODO: Write your name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Hierarchable
|
|
2
|
+
|
|
3
|
+
[](https://dl.circleci.com/status-badge/redirect/gh/prschmid/hierarchable/tree/main)
|
|
4
|
+
|
|
5
|
+
Cross model hierarchical (parent, child, sibling) relationship between ActiveRecord models.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add this line to your application's Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem 'hierarchable'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
And then execute:
|
|
16
|
+
|
|
17
|
+
$ bundle
|
|
18
|
+
|
|
19
|
+
Or install it yourself as:
|
|
20
|
+
|
|
21
|
+
$ gem install hierarchable
|
|
22
|
+
|
|
23
|
+
Once the gem is installed, you will need to make sure that your models have the correct columns.
|
|
24
|
+
|
|
25
|
+
* hierarchy_root: The root node in the hierarchy hierarchy (polymorphic)
|
|
26
|
+
* hierarchy_parent: The parent of the current object (polymorphic)
|
|
27
|
+
* hierarchy_ancestors_path: The string representation of all ancestors of
|
|
28
|
+
the current object (string).
|
|
29
|
+
|
|
30
|
+
Assuming that you are using UUIDs for your IDs, this can be done by adding the following to the model(s) for which you wish to have hierarchy information:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
t.references :hierarchy_root,
|
|
34
|
+
polymorphic: true,
|
|
35
|
+
null: true,
|
|
36
|
+
type: :uuid,
|
|
37
|
+
index: true
|
|
38
|
+
t.references :hierarchy_parent,
|
|
39
|
+
polymorphic: true,
|
|
40
|
+
null: true,
|
|
41
|
+
type: :uuid,
|
|
42
|
+
index: true
|
|
43
|
+
t.string :hierarchy_ancestors_path, index: true
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The `hierarchy_ancestors_path` column does contain all of the information that is in the `hierarchy_root` and `hierarchy_parent` columns, but those two columns are created for more efficient querying as the direct parent and the root are the most frequent parts of the hierarchy that are needed.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
We will describe the usage using a simplistic Project and Task analogy where we assume that a Project can have many tasks. Given a class `Project` we can set it up as follows
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
class Project
|
|
54
|
+
include Hierarchable
|
|
55
|
+
hierarchable
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This will set up the `Project` as the root of the hierarchy. This means that when we query for its root or parent, it will return "self". I.e.
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
project = Project.create!
|
|
63
|
+
|
|
64
|
+
# These will be true (assuming the the ID of the project is the UUID xxxxxxxx-...)
|
|
65
|
+
project.hierarchy_root == project
|
|
66
|
+
project.hierarchy_parent == project
|
|
67
|
+
project.hierarchy_ancestors_path == 'Project|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Now that we have a project configured, we can add tasks that have projects as a parent.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class Task
|
|
74
|
+
include Hierarchable
|
|
75
|
+
hierarchable parent_source: :project
|
|
76
|
+
|
|
77
|
+
belongs_to :project
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This will configure the hierarchy to look at the project association and use that to compute the parent. So now when we instantiate a task, the parent and root of that task will be the project.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
project = Project.create!
|
|
85
|
+
task = Task.create!(project: project)
|
|
86
|
+
|
|
87
|
+
# These will be true
|
|
88
|
+
task.hierarchy_root == project
|
|
89
|
+
task.hierarchy_parent == project
|
|
90
|
+
project.hierarchy_ancestors_path == 'Project|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
|
91
|
+
task.hierarchy_root == project
|
|
92
|
+
task.hierarchy_parent == project
|
|
93
|
+
task.hierarchy_ancestors_path == 'Project|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/Task|yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Now, let's assume that our tasks can also have other Tasks as subtasks. Once we do that, we need to ensure that the parent of a subtask is the task and not the project. For this we, can do something like the following:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class Task
|
|
100
|
+
include Hierarchable
|
|
101
|
+
hierarchable parent_source: ->(object) {
|
|
102
|
+
obj.parent_task.present? ? :parent_task : :project
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
belongs_to :project
|
|
106
|
+
belongs_to :parent_task,
|
|
107
|
+
class_name: 'Task',
|
|
108
|
+
optional: true
|
|
109
|
+
has_many :sub_tasks,
|
|
110
|
+
class_name: 'Task',
|
|
111
|
+
foreign_key: :parent_task
|
|
112
|
+
inverse_of: :parent_task,
|
|
113
|
+
dependent: :destroy
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
What we have done here is configured the source attribute for the hierarchy computation to be `:parent_task` if the task has a `parent_task` set (i.e. it's a subtask), or use `:project` if one is not set (i.e. it's a top level task).
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
project = Project.create!
|
|
121
|
+
task = Task.create!(project: project)
|
|
122
|
+
sub_task = Task.create!(project: project, parent_task: task)
|
|
123
|
+
|
|
124
|
+
# These will be true
|
|
125
|
+
task.hierarchy_root == project
|
|
126
|
+
task.hierarchy_parent == project
|
|
127
|
+
project.hierarchy_ancestors_path == 'Project|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
|
128
|
+
task.hierarchy_root == project
|
|
129
|
+
task.hierarchy_parent == project
|
|
130
|
+
task.hierarchy_ancestors_path == 'Project|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/Task|yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy'
|
|
131
|
+
sub_task.hierarchy_root == project
|
|
132
|
+
sub_task.hierarchy_parent == task
|
|
133
|
+
sub_task.hierarchy_ancestors_path == 'Project|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/Task|yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy/Task|zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Configuring the separators
|
|
137
|
+
|
|
138
|
+
By default the separators to use for the path and records are `/` and `|` respectively. This means that a hierarchy path will look something like
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
<record1_type>|<record1_id>/<record2_type>|<record2_id>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
In the event you need to modify the separators used to build the path, you can pass in your desired separators:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
class SomeObject
|
|
148
|
+
include Hierarchable
|
|
149
|
+
hierarchable parent_source: :parent_object,
|
|
150
|
+
path_separator: '@@',
|
|
151
|
+
record_separator: '++'
|
|
152
|
+
|
|
153
|
+
belongs_to :parent_object
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Assuming that you set the separators like that for all models, then your path will look something like
|
|
158
|
+
```
|
|
159
|
+
<record1_type>++<record1_id>@@<record2_type>++<record2_id>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
CAUTION: When setting custom path and/or record separators, do not use any characters that are likely to be in class/module names such as -, _, :, etc. Otherwise it will not be possible to determine the objects in the path.
|
|
163
|
+
|
|
164
|
+
# Development
|
|
165
|
+
|
|
166
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
167
|
+
|
|
168
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
169
|
+
|
|
170
|
+
## Contributing
|
|
171
|
+
|
|
172
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/prschmid/hierarchable.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << 'test'
|
|
8
|
+
t.libs << 'lib'
|
|
9
|
+
t.test_files = FileList['test/**/*_test.rb']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require 'rubocop/rake_task'
|
|
13
|
+
|
|
14
|
+
RuboCop::RakeTask.new
|
|
15
|
+
|
|
16
|
+
task default: %i[test rubocop]
|
data/bin/console
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "hierarchable"
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
11
|
+
# require "pry"
|
|
12
|
+
# Pry.start
|
|
13
|
+
|
|
14
|
+
require "irb"
|
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require 'hierarchable/version'
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = 'hierarchable'
|
|
9
|
+
spec.version = Hierarchable::VERSION
|
|
10
|
+
spec.authors = ['Patrick R. Schmid']
|
|
11
|
+
spec.email = ['prschmid@gmail.com']
|
|
12
|
+
|
|
13
|
+
spec.summary = 'Cross model hierarchical (parent, child, sibling) ' \
|
|
14
|
+
'relationship between ActiveRecord models.'
|
|
15
|
+
spec.description = 'Cross model hierarchical (parent, child, sibling) ' \
|
|
16
|
+
'relationship between ActiveRecord models.'
|
|
17
|
+
spec.homepage = 'https://github.com/prschmid/hierarchable'
|
|
18
|
+
spec.license = 'MIT'
|
|
19
|
+
|
|
20
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the
|
|
21
|
+
# 'allowed_push_host' to allow pushing to a single host or delete this
|
|
22
|
+
# section to allow pushing to any host.
|
|
23
|
+
if spec.respond_to?(:metadata)
|
|
24
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
25
|
+
spec.metadata['source_code_uri'] = 'https://github.com/prschmid/hierarchable'
|
|
26
|
+
spec.metadata['changelog_uri'] = 'https://github.com/prschmid/hierarchable'
|
|
27
|
+
else
|
|
28
|
+
raise 'RubyGems 2.0 or newer is required to protect against ' \
|
|
29
|
+
'public gem pushes.'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Specify which files should be added to the gem when it is released.
|
|
33
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added
|
|
34
|
+
# into git.
|
|
35
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
36
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
37
|
+
f.match(%r{^(test|spec|features)/})
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
spec.bindir = 'exe'
|
|
41
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
42
|
+
spec.require_paths = ['lib']
|
|
43
|
+
|
|
44
|
+
spec.required_ruby_version = '>= 3.1'
|
|
45
|
+
|
|
46
|
+
spec.add_development_dependency('bundler', '~> 2.3')
|
|
47
|
+
spec.add_development_dependency('bundler-audit', '>= 0')
|
|
48
|
+
spec.add_development_dependency('minitest', '~> 5.0')
|
|
49
|
+
spec.add_development_dependency('rake', '~> 12.3')
|
|
50
|
+
spec.add_development_dependency('rubocop', '>= 0')
|
|
51
|
+
spec.add_development_dependency('rubocop-minitest', '>= 0')
|
|
52
|
+
spec.add_development_dependency('rubocop-performance', '>= 0')
|
|
53
|
+
spec.add_development_dependency('rubocop-rails', '>= 0')
|
|
54
|
+
spec.add_development_dependency('rubocop-rake', '>= 0')
|
|
55
|
+
spec.add_development_dependency('ruby_audit', '>= 0')
|
|
56
|
+
spec.add_development_dependency('sqlite3', '>= 0')
|
|
57
|
+
spec.add_development_dependency('temping', '~> 4.0')
|
|
58
|
+
spec.add_runtime_dependency('activerecord', '> 4.2.0')
|
|
59
|
+
spec.add_runtime_dependency('activesupport', '> 4.2.0')
|
|
60
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
61
|
+
end
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
# All objects that want to make use of this concern must have three columns
|
|
6
|
+
# defined:
|
|
7
|
+
# hierarchy_root: The root node in the hierarchy hierarchy (polymorphic)
|
|
8
|
+
# hierarchy_parent: The parent of the current object (polymorphic)
|
|
9
|
+
# hierarchy_ancestors_path: The string representation of all ancestors of
|
|
10
|
+
# the current object (string).
|
|
11
|
+
# The `hierarchy_ancestors_path` column does contain all of the information
|
|
12
|
+
# that is in the `hierarchy_root` and `hierarchy_parent` columns, but those two
|
|
13
|
+
# columns are created for more efficient querying as the direct parent and the
|
|
14
|
+
# root are the most frequent parts of the hierarchy that are needed.
|
|
15
|
+
#
|
|
16
|
+
# To set the attribute that should be used as the parent, one needs to set the
|
|
17
|
+
# `parent_source` value when including this in a model.
|
|
18
|
+
#
|
|
19
|
+
# class A
|
|
20
|
+
# include Hierarchable
|
|
21
|
+
# hierarchable parent_source: :some_column
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# If some model doesn't have a parent (e.g. it's the root of the hierarchy)
|
|
25
|
+
# then the `parent_source` can be ommited or explicitly set to `nil`
|
|
26
|
+
#
|
|
27
|
+
# class B
|
|
28
|
+
# include Hierarchable
|
|
29
|
+
# hierarchable parent_source: nil
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# class AlternateB
|
|
33
|
+
# include Hierarchable
|
|
34
|
+
# hierarchable
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# There are times when the parent is dependent on the state of an object. For
|
|
38
|
+
# example, let's assume that a Project can have tasks, and that tasks can also
|
|
39
|
+
# have tasks (sub tasks). Assuming that the Task model has both `project`
|
|
40
|
+
# and `parent_task` attributes, we could define the parent source dynamically
|
|
41
|
+
# as follows:
|
|
42
|
+
#
|
|
43
|
+
# class Task
|
|
44
|
+
# include Hierarchable
|
|
45
|
+
# hierarchable parent_source: ->(obj) {
|
|
46
|
+
# obj.parent_task.present ? :parent_task : :project
|
|
47
|
+
# }
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# By default the separators to use for the path and records are / and |
|
|
51
|
+
# respectively. This means that a hierarchy path will look something like
|
|
52
|
+
#
|
|
53
|
+
# <record1_type>|<record1_id>/<record2_type>|<record2_id>
|
|
54
|
+
#
|
|
55
|
+
# A user can change this default behavior by setting the `path_separator` and/or
|
|
56
|
+
# `record_seprator` option when including this in a class. For example:
|
|
57
|
+
#
|
|
58
|
+
# class Foo
|
|
59
|
+
# include Hierarchable
|
|
60
|
+
# hierachable path_separator: '##', record_separator: '@@'
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# CAUTION: When setting custom path and/or record separators, do not use any
|
|
64
|
+
# characters that are likely to be in class/module names such as -, _, :, etc.
|
|
65
|
+
module Hierarchable
|
|
66
|
+
extend ActiveSupport::Concern
|
|
67
|
+
|
|
68
|
+
HIERARCHABLE_DEFAULT_PATH_SEPARATOR = '/'
|
|
69
|
+
HIERARCHABLE_DEFAULT_RECORD_SEPARATOR = '|'
|
|
70
|
+
|
|
71
|
+
class_methods do
|
|
72
|
+
def hierarchable(opts = {})
|
|
73
|
+
class_attribute :hierarchable_config
|
|
74
|
+
|
|
75
|
+
# Save the configuration
|
|
76
|
+
self.hierarchable_config = {
|
|
77
|
+
parent_source: opts.fetch(:parent_source, nil),
|
|
78
|
+
path_separator: opts.fetch(
|
|
79
|
+
:path_separator, HIERARCHABLE_DEFAULT_PATH_SEPARATOR
|
|
80
|
+
),
|
|
81
|
+
record_separator: opts.fetch(
|
|
82
|
+
:record_separator, HIERARCHABLE_DEFAULT_RECORD_SEPARATOR
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
belongs_to :hierarchy_root, polymorphic: true, optional: true
|
|
87
|
+
|
|
88
|
+
belongs_to :hierarchy_parent, polymorphic: true, optional: true
|
|
89
|
+
alias_method :hierarchy_parent_relationship, :hierarchy_parent
|
|
90
|
+
|
|
91
|
+
# Set the parent of the current object. This needs to happen first as
|
|
92
|
+
# setting the hierarchy_root and the hierarchy_ancestors_path depends on
|
|
93
|
+
# having the hierarchy_parent set first.
|
|
94
|
+
before_save :set_hierarchy_parent
|
|
95
|
+
|
|
96
|
+
# Based on the hierarchy_parent that is set, set the root. This will take
|
|
97
|
+
# the hierarchy_root of the hierarchy_parent.
|
|
98
|
+
before_save :set_hierarchy_root
|
|
99
|
+
|
|
100
|
+
# If an object gets moved, we need to ensure that then
|
|
101
|
+
# hierarchy_ancestors_path is updated to ensure that it stays accurate
|
|
102
|
+
before_save :update_dirty_hierarchy_ancestors_path,
|
|
103
|
+
unless: :new_record?,
|
|
104
|
+
if: :hierarchy_parent_changed?
|
|
105
|
+
|
|
106
|
+
before_create :set_hierarchy_ancestors_path
|
|
107
|
+
|
|
108
|
+
scope :descendants_of,
|
|
109
|
+
lambda { |object|
|
|
110
|
+
where(
|
|
111
|
+
'hierarchy_ancestors_path LIKE :hierarchy_ancestors_path',
|
|
112
|
+
hierarchy_ancestors_path: "#{object.hierarchy_full_path}%"
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
scope :siblings_of,
|
|
117
|
+
lambda { |object|
|
|
118
|
+
where(
|
|
119
|
+
'hierarchy_parent_type=:parent_type AND hierarchy_parent_id=:parent_id',
|
|
120
|
+
parent_type: object.hierarchy_parent.class.name,
|
|
121
|
+
parent_id: object.hierarchy_parent.id
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
include InstanceMethods
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Instance methods to include
|
|
130
|
+
module InstanceMethods
|
|
131
|
+
def hierarchy_parent(raw: false)
|
|
132
|
+
return hierarchy_parent_relationship if raw
|
|
133
|
+
|
|
134
|
+
# Depending on whether or not the object has been saved or not, we need
|
|
135
|
+
# to be smart as to how we try to get the parent. If it's saved, then
|
|
136
|
+
# the `hierarchy_parent` attribute in the model will be set and so we
|
|
137
|
+
# can use the `belongs_to` relationship to get the parent. However,
|
|
138
|
+
# if the parent has changed or the object has yet to be saved, we can't
|
|
139
|
+
# use the relationship to get the parent as the value will not have been
|
|
140
|
+
# set properly yet in the model (since it's a `before_save` hook).
|
|
141
|
+
use_relationship = if persisted?
|
|
142
|
+
!hierarchy_parent_changed?
|
|
143
|
+
else
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if use_relationship
|
|
148
|
+
hierarchy_parent_relationship
|
|
149
|
+
else
|
|
150
|
+
source = hierarchy_parent_source
|
|
151
|
+
source.nil? ? nil : send(source)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Return the attribute name that links this object to its parent.
|
|
156
|
+
#
|
|
157
|
+
# This should return the name of the attribute/relation/etc either as a
|
|
158
|
+
# string or symbol.
|
|
159
|
+
#
|
|
160
|
+
# For example, if this is a Task, then the hierarchy_parent_source is
|
|
161
|
+
# likely the attribute that references the Project this task belongs to.
|
|
162
|
+
# If the method returns nil (the default behavior), the assumption is that
|
|
163
|
+
# this object is the root of the hierarchy.
|
|
164
|
+
def hierarchy_parent_source
|
|
165
|
+
source = hierarchable_config[:parent_source]
|
|
166
|
+
return nil unless source
|
|
167
|
+
|
|
168
|
+
source.respond_to?(:call) ? source.call(self) : source
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Return the string representation of the current object in the format when
|
|
172
|
+
# used as part of a hierarchy.
|
|
173
|
+
#
|
|
174
|
+
# If this is a new record (i.e. not saved yet), this will return "", and
|
|
175
|
+
# will return the string representation of the format once it is saved.
|
|
176
|
+
def to_hierarchy_ancestors_path_format
|
|
177
|
+
return '' if new_record?
|
|
178
|
+
|
|
179
|
+
to_hierarchy_format(self)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Return the full hierarchy path from the root to this object.
|
|
183
|
+
#
|
|
184
|
+
# Unlike the hierarchy_ancestors_path which DOES NOT include the current
|
|
185
|
+
# object in the path, this path contains both the ancestors path AND
|
|
186
|
+
# the current object.
|
|
187
|
+
def hierarchy_full_path
|
|
188
|
+
return '' if new_record? ||
|
|
189
|
+
!respond_to?(:hierarchy_ancestors_path)
|
|
190
|
+
|
|
191
|
+
if hierarchy_ancestors_path.present?
|
|
192
|
+
format('%<path>s%<sep>s%<current>s',
|
|
193
|
+
path: hierarchy_ancestors_path,
|
|
194
|
+
sep: hierarchable_config[:path_separator],
|
|
195
|
+
current: to_hierarchy_ancestors_path_format)
|
|
196
|
+
else
|
|
197
|
+
to_hierarchy_ancestors_path_format
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Return hierarchy path for given list of objects
|
|
202
|
+
|
|
203
|
+
def hierarchy_path_for(objects)
|
|
204
|
+
return '' if objects.blank?
|
|
205
|
+
|
|
206
|
+
objects.map do |obj|
|
|
207
|
+
to_hierarchy_format(obj)
|
|
208
|
+
end.join(hierarchable_config[:path_separator])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def to_hierarchy_format(object)
|
|
212
|
+
"#{object.class}#{hierarchable_config[:record_separator]}#{object.id}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Return the full hierarchy path from the root to this object as objects.
|
|
216
|
+
#
|
|
217
|
+
# Unlike the hierarchy_full_path that returns a string of the path,
|
|
218
|
+
# this returns a list of items. The pattern of the returned list will be
|
|
219
|
+
#
|
|
220
|
+
# [Class, Object, Class, Object, ...]
|
|
221
|
+
#
|
|
222
|
+
# Where the Class is the class of the object coming right after it. This
|
|
223
|
+
# representation is useful when creating a breadcrumb and we want to
|
|
224
|
+
# have both all the ancestors (like in the ancestors method), but also
|
|
225
|
+
# the collections (classes), so that we can build up a nice path with
|
|
226
|
+
# links.
|
|
227
|
+
def hierarchy_full_path_reified
|
|
228
|
+
return '' if new_record? ||
|
|
229
|
+
!respond_to?(:hierarchy_ancestors_path)
|
|
230
|
+
|
|
231
|
+
path = []
|
|
232
|
+
hierarchy_full_path.split(hierarchable_config[:path_separator])
|
|
233
|
+
.each do |record|
|
|
234
|
+
ancestor_class_name, ancestor_id = record.split(
|
|
235
|
+
hierarchable_config[:record_separator]
|
|
236
|
+
)
|
|
237
|
+
ancestor_class = ancestor_class_name.safe_constantize
|
|
238
|
+
path << ancestor_class
|
|
239
|
+
path << ancestor_class.find(ancestor_id)
|
|
240
|
+
end
|
|
241
|
+
path
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Get ancestors of the same type for an object.
|
|
245
|
+
#
|
|
246
|
+
# For a given object type, return all ancestors that have the same type.
|
|
247
|
+
# Note, since ancestors may be of different types, this may skip parts
|
|
248
|
+
# of the hierarchy if the particular ancestor happens to be of a different
|
|
249
|
+
# type.
|
|
250
|
+
def ancestors
|
|
251
|
+
return [] if !respond_to?(:hierarchy_ancestors_path) ||
|
|
252
|
+
hierarchy_ancestors_path.blank?
|
|
253
|
+
|
|
254
|
+
a = hierarchy_ancestors_path.split(
|
|
255
|
+
hierarchable_config[:path_separator]
|
|
256
|
+
).map do |ancestor|
|
|
257
|
+
ancestor_class, ancestor_id = ancestor.split(
|
|
258
|
+
hierarchable_config[:record_separator]
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if ancestor_class == self.class.name
|
|
262
|
+
ancestor_class.safe_constantize.find(ancestor_id)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
a.compact
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Return the list of all ancestor objects for the current object
|
|
269
|
+
#
|
|
270
|
+
# Using the `hierarchy_ancestors_path`, this will iteratively get all
|
|
271
|
+
# ancestor objects and return them as a list.
|
|
272
|
+
#
|
|
273
|
+
# As there may be ancestors of different types, this is not a single query
|
|
274
|
+
# and may return things of many different types. E.g. if we have a Project,
|
|
275
|
+
# Task, and a Comment, the ancestors of a coment may be the Task and the
|
|
276
|
+
# Project.
|
|
277
|
+
def all_ancestors
|
|
278
|
+
return [] if !respond_to?(:hierarchy_ancestors_path) ||
|
|
279
|
+
hierarchy_ancestors_path.blank?
|
|
280
|
+
|
|
281
|
+
hierarchy_ancestors_path.split(
|
|
282
|
+
hierarchable_config[:path_separator]
|
|
283
|
+
).map do |ancestor|
|
|
284
|
+
ancestor_class, ancestor_id = ancestor.split(
|
|
285
|
+
hierarchable_config[:record_separator]
|
|
286
|
+
)
|
|
287
|
+
ancestor_class.safe_constantize.find(ancestor_id)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Get siblings of the same type for an object.
|
|
292
|
+
#
|
|
293
|
+
# For a given object type, return all siblings. Note, this DOES NOT return
|
|
294
|
+
# siblings of different types and those need to be queried separetly.
|
|
295
|
+
# equivalent to c.hierarchy_parent.children
|
|
296
|
+
#
|
|
297
|
+
# Params:
|
|
298
|
+
# +include_self+:: Whether or not to include self in the list.
|
|
299
|
+
# Default is true
|
|
300
|
+
def siblings(include_self: true)
|
|
301
|
+
# The method should always return relation, not an Array sometimes and
|
|
302
|
+
# Relation the other
|
|
303
|
+
return self.class.none unless respond_to?(:hierarchy_parent_id)
|
|
304
|
+
|
|
305
|
+
query = self.class.where(
|
|
306
|
+
hierarchy_parent_type: public_send(:hierarchy_parent_type),
|
|
307
|
+
hierarchy_parent_id: public_send(:hierarchy_parent_id)
|
|
308
|
+
)
|
|
309
|
+
query = query.where.not(id:) unless include_self
|
|
310
|
+
query
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Get all siblings of this object regardless of object type.
|
|
314
|
+
#
|
|
315
|
+
# This has yet to be implemented and would likely require a separate join
|
|
316
|
+
# table that has all of the data across all tables linked to the particular
|
|
317
|
+
# parent. I.e. a simple table that has parent, child in it that we could
|
|
318
|
+
# use to query.
|
|
319
|
+
#
|
|
320
|
+
# Params:
|
|
321
|
+
# +include_self+:: Whether or not to include self in the list.
|
|
322
|
+
# Default is true
|
|
323
|
+
def all_siblings
|
|
324
|
+
raise NotImplementedError
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
protected
|
|
328
|
+
|
|
329
|
+
# Set the hierarchy_parent of the current object.
|
|
330
|
+
#
|
|
331
|
+
# This will look at the `hierarchy_parent_source` and take the value that
|
|
332
|
+
# is returned by that method and use it to set the parent. If the parent
|
|
333
|
+
# is set to `nil`, the assumption is that this object is then the root of
|
|
334
|
+
# the hierarchy.
|
|
335
|
+
def set_hierarchy_parent
|
|
336
|
+
return unless respond_to?(:hierarchy_parent_id)
|
|
337
|
+
return if hierarchy_parent_source.blank?
|
|
338
|
+
|
|
339
|
+
self.hierarchy_parent = public_send(hierarchy_parent_source)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Set the hierarchy_root of the current object.
|
|
343
|
+
#
|
|
344
|
+
# This will look at the `hierarchy_parent` and take the `hierarchy_root` of
|
|
345
|
+
# that object. Since this looks at the `hierarchy_parent`, it is imperative
|
|
346
|
+
# that the `hierarchy_parent` is set before this method is called.
|
|
347
|
+
def set_hierarchy_root
|
|
348
|
+
return unless respond_to?(:hierarchy_root_id) &&
|
|
349
|
+
respond_to?(:hierarchy_parent_id)
|
|
350
|
+
|
|
351
|
+
parent = hierarchy_parent
|
|
352
|
+
self.hierarchy_root = if parent.respond_to?(:hierarchy_root)
|
|
353
|
+
if parent.hierarchy_root.nil?
|
|
354
|
+
parent
|
|
355
|
+
else
|
|
356
|
+
parent.hierarchy_root
|
|
357
|
+
end
|
|
358
|
+
else
|
|
359
|
+
parent
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Set the hierarchy_ancestors_path of the current object.
|
|
364
|
+
#
|
|
365
|
+
# Based on the hierarchy_parent, this will append the necessary information
|
|
366
|
+
# to update the hierarchy_ancestors_path
|
|
367
|
+
def set_hierarchy_ancestors_path
|
|
368
|
+
return unless respond_to?(:hierarchy_ancestors_path)
|
|
369
|
+
return unless respond_to?(:hierarchy_parent)
|
|
370
|
+
|
|
371
|
+
parent = hierarchy_parent
|
|
372
|
+
self.hierarchy_ancestors_path = \
|
|
373
|
+
if parent.nil? || !parent.respond_to?(:hierarchy_ancestors_path)
|
|
374
|
+
nil
|
|
375
|
+
elsif parent.hierarchy_ancestors_path.blank?
|
|
376
|
+
parent.to_hierarchy_ancestors_path_format
|
|
377
|
+
else
|
|
378
|
+
format('%<path>s%<sep>s%<current>s',
|
|
379
|
+
path: parent.hierarchy_ancestors_path,
|
|
380
|
+
sep: hierarchable_config[:path_separator],
|
|
381
|
+
current: parent.to_hierarchy_ancestors_path_format)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Check to see if the hierarchy_parent has changed
|
|
386
|
+
#
|
|
387
|
+
# This will take the `hierarchy_parent_source` and check to see if the
|
|
388
|
+
# current objects's value for the ID corresponding to the
|
|
389
|
+
# `hierarchy_parent_source` has been updated.
|
|
390
|
+
def hierarchy_parent_changed?
|
|
391
|
+
# FIXME: We need to figure out how to deal with updating the
|
|
392
|
+
# object_hierarchy_ancestry_path, object_hierarchy_full_path, etc.,
|
|
393
|
+
if hierarchy_parent_source.present?
|
|
394
|
+
public_send("#{hierarchy_parent_source}_id_changed?")
|
|
395
|
+
else
|
|
396
|
+
false
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Update the hierarchy_ancestors_path if the hierarchy has changed.
|
|
401
|
+
def update_dirty_hierarchy_ancestors_path
|
|
402
|
+
set_hierarchy_ancestors_path
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
data/lib/hierarchable.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hierarchable/version'
|
|
4
|
+
require File.join(File.dirname(__FILE__), 'hierarchable', 'hierarchable')
|
|
5
|
+
|
|
6
|
+
ActiveRecord::Base.instance_eval { include Hierarchable }
|
|
7
|
+
if defined?(Rails) && Rails.version.to_i < 4
|
|
8
|
+
raise 'This version of hierarchable requires Rails 4 or higher'
|
|
9
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hierarchable
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Patrick R. Schmid
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2022-12-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: bundler
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.3'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.3'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: bundler-audit
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: minitest
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '5.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '5.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '12.3'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '12.3'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rubocop-minitest
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: rubocop-performance
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: rubocop-rails
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '0'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0'
|
|
125
|
+
- !ruby/object:Gem::Dependency
|
|
126
|
+
name: rubocop-rake
|
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '0'
|
|
132
|
+
type: :development
|
|
133
|
+
prerelease: false
|
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '0'
|
|
139
|
+
- !ruby/object:Gem::Dependency
|
|
140
|
+
name: ruby_audit
|
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - ">="
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '0'
|
|
146
|
+
type: :development
|
|
147
|
+
prerelease: false
|
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - ">="
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '0'
|
|
153
|
+
- !ruby/object:Gem::Dependency
|
|
154
|
+
name: sqlite3
|
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
|
156
|
+
requirements:
|
|
157
|
+
- - ">="
|
|
158
|
+
- !ruby/object:Gem::Version
|
|
159
|
+
version: '0'
|
|
160
|
+
type: :development
|
|
161
|
+
prerelease: false
|
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
163
|
+
requirements:
|
|
164
|
+
- - ">="
|
|
165
|
+
- !ruby/object:Gem::Version
|
|
166
|
+
version: '0'
|
|
167
|
+
- !ruby/object:Gem::Dependency
|
|
168
|
+
name: temping
|
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
|
170
|
+
requirements:
|
|
171
|
+
- - "~>"
|
|
172
|
+
- !ruby/object:Gem::Version
|
|
173
|
+
version: '4.0'
|
|
174
|
+
type: :development
|
|
175
|
+
prerelease: false
|
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
177
|
+
requirements:
|
|
178
|
+
- - "~>"
|
|
179
|
+
- !ruby/object:Gem::Version
|
|
180
|
+
version: '4.0'
|
|
181
|
+
- !ruby/object:Gem::Dependency
|
|
182
|
+
name: activerecord
|
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
|
184
|
+
requirements:
|
|
185
|
+
- - ">"
|
|
186
|
+
- !ruby/object:Gem::Version
|
|
187
|
+
version: 4.2.0
|
|
188
|
+
type: :runtime
|
|
189
|
+
prerelease: false
|
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
191
|
+
requirements:
|
|
192
|
+
- - ">"
|
|
193
|
+
- !ruby/object:Gem::Version
|
|
194
|
+
version: 4.2.0
|
|
195
|
+
- !ruby/object:Gem::Dependency
|
|
196
|
+
name: activesupport
|
|
197
|
+
requirement: !ruby/object:Gem::Requirement
|
|
198
|
+
requirements:
|
|
199
|
+
- - ">"
|
|
200
|
+
- !ruby/object:Gem::Version
|
|
201
|
+
version: 4.2.0
|
|
202
|
+
type: :runtime
|
|
203
|
+
prerelease: false
|
|
204
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
205
|
+
requirements:
|
|
206
|
+
- - ">"
|
|
207
|
+
- !ruby/object:Gem::Version
|
|
208
|
+
version: 4.2.0
|
|
209
|
+
description: Cross model hierarchical (parent, child, sibling) relationship between
|
|
210
|
+
ActiveRecord models.
|
|
211
|
+
email:
|
|
212
|
+
- prschmid@gmail.com
|
|
213
|
+
executables: []
|
|
214
|
+
extensions: []
|
|
215
|
+
extra_rdoc_files: []
|
|
216
|
+
files:
|
|
217
|
+
- ".circleci/config.yml"
|
|
218
|
+
- ".gitignore"
|
|
219
|
+
- ".rubocop.yml"
|
|
220
|
+
- ".ruby-gemset"
|
|
221
|
+
- ".ruby-version"
|
|
222
|
+
- Gemfile
|
|
223
|
+
- Gemfile.lock
|
|
224
|
+
- LICENSE.txt
|
|
225
|
+
- README.md
|
|
226
|
+
- Rakefile
|
|
227
|
+
- bin/console
|
|
228
|
+
- bin/setup
|
|
229
|
+
- hierarchable.gemspec
|
|
230
|
+
- lib/hierarchable.rb
|
|
231
|
+
- lib/hierarchable/hierarchable.rb
|
|
232
|
+
- lib/hierarchable/version.rb
|
|
233
|
+
homepage: https://github.com/prschmid/hierarchable
|
|
234
|
+
licenses:
|
|
235
|
+
- MIT
|
|
236
|
+
metadata:
|
|
237
|
+
homepage_uri: https://github.com/prschmid/hierarchable
|
|
238
|
+
source_code_uri: https://github.com/prschmid/hierarchable
|
|
239
|
+
changelog_uri: https://github.com/prschmid/hierarchable
|
|
240
|
+
rubygems_mfa_required: 'true'
|
|
241
|
+
post_install_message:
|
|
242
|
+
rdoc_options: []
|
|
243
|
+
require_paths:
|
|
244
|
+
- lib
|
|
245
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
246
|
+
requirements:
|
|
247
|
+
- - ">="
|
|
248
|
+
- !ruby/object:Gem::Version
|
|
249
|
+
version: '3.1'
|
|
250
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
251
|
+
requirements:
|
|
252
|
+
- - ">="
|
|
253
|
+
- !ruby/object:Gem::Version
|
|
254
|
+
version: '0'
|
|
255
|
+
requirements: []
|
|
256
|
+
rubygems_version: 3.3.26
|
|
257
|
+
signing_key:
|
|
258
|
+
specification_version: 4
|
|
259
|
+
summary: Cross model hierarchical (parent, child, sibling) relationship between ActiveRecord
|
|
260
|
+
models.
|
|
261
|
+
test_files: []
|