ancestry 4.2.0 → 4.3.1
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 +4 -4
- data/CHANGELOG.md +19 -1
- data/README.md +332 -139
- data/lib/ancestry/class_methods.rb +21 -15
- data/lib/ancestry/has_ancestry.rb +23 -34
- data/lib/ancestry/instance_methods.rb +17 -15
- data/lib/ancestry/locales/en.yml +1 -0
- data/lib/ancestry/materialized_path.rb +58 -26
- data/lib/ancestry/materialized_path2.rb +36 -27
- data/lib/ancestry/materialized_path_pg.rb +6 -6
- data/lib/ancestry/version.rb +1 -1
- data/lib/ancestry.rb +38 -1
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1628363389f64272d25675e727b155ba36c221404a95fc7d7d41de12b2185614
|
4
|
+
data.tar.gz: 2a76d777929b18efa86bde34b969f11ab58c3f44424e82c97e69ef8f348d1c96
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5ece87f9d1577748ffbaaf8617afdc4bf4094f653300b4a2df043a5f16822b267da877f0689b7d792f5f2174f1b4e1b41333e89c0e9dc23c388af30712267953
|
7
|
+
data.tar.gz: a12ce97c69b384100eecf3a347dcd3fea0abb60193c71b0e6a83d667c8f6a4cfdeba7e401e743bf95cf59f4d87ddc550d63b258ac294dd057ca7c518f1c8540f
|
data/CHANGELOG.md
CHANGED
@@ -3,6 +3,23 @@
|
|
3
3
|
Doing our best at supporting [SemVer](http://semver.org/) with
|
4
4
|
a nice looking [Changelog](http://keepachangelog.com).
|
5
5
|
|
6
|
+
## Version [4.3.1] <sub><sup>2023-03-19</sub></sup>
|
7
|
+
* Fix: added back fields that were removed in #589 [#637](https://github.com/stefankroes/ancestry/pull/637) (thx @znz)
|
8
|
+
- ancestor_ids_in_database
|
9
|
+
- parent_id_in_database
|
10
|
+
|
11
|
+
## Version [4.3.0] <sub><sup>2023-03-09</sub></sup>
|
12
|
+
|
13
|
+
* Fix: materialized_path2 strategy [#597](https://github.com/stefankroes/ancestry/pull/597) (thx @kshnurov)
|
14
|
+
* Fix: descendants ancestry is now updated in after_update callbacks [#589](https://github.com/stefankroes/ancestry/pull/589) (thx @kshnurov)
|
15
|
+
* Document updated grammar [#594](https://github.com/stefankroes/ancestry/pull/594) (thx @omarr-gamal)
|
16
|
+
* Documented `update_strategy` [#588](https://github.com/stefankroes/ancestry/pull/588) (thx @victorfgs)
|
17
|
+
* Fix: fixed has_parent? when non-default primary id [#585](https://github.com/stefankroes/ancestry/pull/585) (thx @Zhong-z)
|
18
|
+
* Documented column collation and testing [#601](https://github.com/stefankroes/ancestry/pull/601) [#607](https://github.com/stefankroes/ancestry/pull/607) (thx @kshnurov)
|
19
|
+
* Added initializer with default_ancestry_format [#612](https://github.com/stefankroes/ancestry/pull/612) [#613](https://github.com/stefankroes/ancestry/pull/613)
|
20
|
+
* ruby 3.2 support [#596](https://github.com/stefankroes/ancestry/pull/596) (thx @petergoldstein)
|
21
|
+
* arrange is 3x faster and uses 20-30x less memory [#415](https://github.com/stefankroes/ancestry/pull/415)
|
22
|
+
|
6
23
|
## Version [4.2.0] <sub><sup>2022-06-09</sub></sup>
|
7
24
|
|
8
25
|
* added strategy: materialized_path2 [#571](https://github.com/stefankroes/ancestry/pull/571)
|
@@ -270,7 +287,8 @@ Missed 2 commits (which are feature adds)
|
|
270
287
|
* Validations
|
271
288
|
|
272
289
|
|
273
|
-
[HEAD]: https://github.com/stefankroes/ancestry/compare/v4.
|
290
|
+
[HEAD]: https://github.com/stefankroes/ancestry/compare/v4.3.0...HEAD
|
291
|
+
[4.3.0]: https://github.com/stefankroes/ancestry/compare/v4.2.0...v4.3.0
|
274
292
|
[4.2.0]: https://github.com/stefankroes/ancestry/compare/v4.1.0...v4.2.0
|
275
293
|
[4.1.0]: https://github.com/stefankroes/ancestry/compare/v4.0.0...v4.1.0
|
276
294
|
[4.0.0]: https://github.com/stefankroes/ancestry/compare/v3.2.1...v4.0.0
|
data/README.md
CHANGED
@@ -2,29 +2,58 @@
|
|
2
2
|
|
3
3
|
# Ancestry
|
4
4
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
Ancestry is a gem that allows rails ActiveRecord models to be organized as
|
8
|
+
a tree structure (or hierarchy). It employs the materialized path pattern
|
9
|
+
which allows operations to be performed efficiently.
|
10
|
+
|
11
|
+
# Features
|
12
|
+
|
13
|
+
There are a few common ways of storing hierarchical data in a database:
|
14
|
+
materialized path, closure tree table, adjacency lists, nested sets, and adjacency list with recursive queries.
|
15
|
+
|
16
|
+
## Features from Materialized Path
|
17
|
+
|
18
|
+
- Store hierarchy in an easy to understand format. (e.g.: `/1/2/3/`)
|
19
|
+
- Store hierarchy in the original table with no additional tables.
|
20
|
+
- Single SQL queries for relations (`ancestors`, `parent`, `root`, `children`, `siblings`, `descendants`)
|
21
|
+
- Single query for creating records.
|
22
|
+
- Moving/deleting nodes only affect child nodes (rather than updating all nodes in the tree)
|
23
|
+
|
24
|
+
## Features from Ancestry gem Implementation
|
25
|
+
|
26
|
+
- relations are implemented as `scopes`
|
27
|
+
- `STI` support
|
28
|
+
- Arrangement of subtrees into hashes
|
29
|
+
- Multiple strategies for querying materialized_path
|
30
|
+
- Multiple strategies for dealing with orphaned records
|
31
|
+
- depth caching
|
32
|
+
- depth constraints
|
33
|
+
- counter caches
|
34
|
+
- Multiple strategies for moving nodes
|
35
|
+
- Easy migration from `parent_id` based gems
|
36
|
+
- Integrity checking
|
37
|
+
- Integrity restoration
|
38
|
+
- Most queries use indexes on `id` or `ancestry` column. (e.g.: `LIKE '#{ancestry}/%'`)
|
39
|
+
|
40
|
+
Since a Btree index has a limitaton of 2704 characters for the `ancestry` column,
|
41
|
+
the maximum depth of an ancestry tree is 900 items at most. If ids are 4 digits long,
|
42
|
+
then the max depth is 540 items.
|
43
|
+
|
44
|
+
When using `STI` all classes are returned from the scopes unless you specify otherwise using `where(:type => "ChildClass")`.
|
45
|
+
|
46
|
+
## Supported Rails versions
|
16
47
|
|
17
48
|
- Ancestry 2.x supports Rails 4.1 and earlier
|
18
49
|
- Ancestry 3.x supports Rails 5.0 and 4.2
|
19
|
-
- Ancestry 4.
|
50
|
+
- Ancestry 4.x only supports rails 5.2 and higher
|
20
51
|
|
21
52
|
# Installation
|
22
53
|
|
23
|
-
Follow these
|
54
|
+
Follow these steps to apply Ancestry to any ActiveRecord model:
|
24
55
|
|
25
|
-
##
|
26
|
-
|
27
|
-
* Add to Gemfile:
|
56
|
+
## Add to Gemfile
|
28
57
|
|
29
58
|
```ruby
|
30
59
|
# Gemfile
|
@@ -32,73 +61,68 @@ Follow these simple steps to apply Ancestry to any ActiveRecord model:
|
|
32
61
|
gem 'ancestry'
|
33
62
|
```
|
34
63
|
|
35
|
-
* Install required gems:
|
36
|
-
|
37
64
|
```bash
|
38
65
|
$ bundle install
|
39
66
|
```
|
40
67
|
|
41
|
-
|
42
68
|
## Add ancestry column to your table
|
43
|
-
* Create migration:
|
44
69
|
|
45
70
|
```bash
|
46
|
-
$ rails g migration
|
47
|
-
|
48
|
-
|
71
|
+
$ rails g migration add_[ancestry]_to_[table] ancestry:string:index
|
72
|
+
```
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
class AddAncestryToTable < ActiveRecord::Migration[6.1]
|
76
|
+
def change
|
77
|
+
change_table(:table) do |t|
|
78
|
+
# postgrel
|
79
|
+
t.string "ancestry", collation: 'C', null: false
|
80
|
+
t.index "ancestry"
|
81
|
+
# mysql
|
82
|
+
t.string "ancestry", collation: 'utf8mb4_bin', null: false
|
83
|
+
t.index "ancestry"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
49
87
|
```
|
50
88
|
|
51
|
-
|
89
|
+
There are additional options for the columns in [Ancestry Database Columnl](#ancestry-database-column) and
|
90
|
+
an explanation for `opclass` and `collation`.
|
52
91
|
|
53
92
|
```bash
|
54
93
|
$ rake db:migrate
|
55
94
|
```
|
56
95
|
|
57
|
-
|
58
|
-
with `C` or `POSIX` encoding. This is a more primitive encoding and just compares
|
59
|
-
bytes. Since this column will just contains numbers and slashes, it works much
|
60
|
-
better. It also works better for the uuid case as well.
|
61
|
-
|
62
|
-
Alternatively, if you create a [`text_pattern_ops`](https://www.postgresql.org/docs/current/indexes-opclass.html) index for your postgresql column, subtree selection will use an efficient index for you regardless of whether you created the column with `POSIX` encoding.
|
96
|
+
## Configure ancestry defaults
|
63
97
|
|
64
|
-
|
65
|
-
|
66
|
-
on this topic is out of scope. The important take away is postgres sort order is
|
67
|
-
not consistent across operating systems but other databases do not have this same
|
68
|
-
issue.
|
98
|
+
```ruby
|
99
|
+
# config/initializers/ancestry.rb
|
69
100
|
|
70
|
-
|
101
|
+
# use the newer format
|
102
|
+
Ancestry.default_ancestry_format = :materialized_path2
|
103
|
+
# Ancestry.default_update_strategy = :sql
|
104
|
+
```
|
71
105
|
|
72
106
|
## Add ancestry to your model
|
73
|
-
* Add to app/models/[model.rb]:
|
74
107
|
|
75
108
|
```ruby
|
76
109
|
# app/models/[model.rb]
|
77
110
|
|
78
111
|
class [Model] < ActiveRecord::Base
|
79
|
-
has_ancestry
|
80
|
-
# has_ancestry ancestry_column: :name ## if you've used a different column name
|
112
|
+
has_ancestry
|
81
113
|
end
|
82
114
|
```
|
83
115
|
|
84
116
|
Your model is now a tree!
|
85
117
|
|
86
|
-
# Using acts_as_tree instead of has_ancestry
|
87
|
-
|
88
|
-
In version 1.2.0, the **acts_as_tree** method was **renamed to has_ancestry**
|
89
|
-
in order to allow usage of both the acts_as_tree gem and the ancestry gem in a
|
90
|
-
single application. The `acts_as_tree` method will continue to be supported in the future.
|
91
|
-
|
92
118
|
# Organising records into a tree
|
93
119
|
|
94
|
-
You can use
|
95
|
-
|
96
|
-
it, you can also use parent_id. Like any virtual model attributes, parent and
|
97
|
-
parent_id can be set using parent= and parent_id= on a record or by including
|
98
|
-
them in the hash passed to new, create, create!, update_attributes and
|
99
|
-
update_attributes!. For example:
|
120
|
+
You can use `parent_id` and `parent` to add a node into a tree. They can be
|
121
|
+
set as attributes or passed into methods like `new`, `create`, and `update`.
|
100
122
|
|
101
|
-
|
123
|
+
```ruby
|
124
|
+
TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky')
|
125
|
+
```
|
102
126
|
|
103
127
|
Children can be created through the children relation on a node: `node.children.create :name => 'Stinky'`.
|
104
128
|
|
@@ -127,31 +151,47 @@ The yellow nodes are those returned by the method.
|
|
127
151
|
|`has_siblings?` | | |
|
128
152
|
|`sibling_of?(node)` | | |
|
129
153
|
|
154
|
+
When using `STI` all classes are returned from the scopes unless you specify otherwise using `where(:type => "ChildClass")`.
|
155
|
+
|
130
156
|
<sup id="fn1">1. [other root records are considered siblings]<a href="#ref1" title="Jump back to footnote 1.">↩</a></sup>
|
131
157
|
|
132
|
-
#
|
158
|
+
# has_ancestry options
|
133
159
|
|
134
|
-
The has_ancestry method supports the following options:
|
160
|
+
The `has_ancestry` method supports the following options:
|
135
161
|
|
136
|
-
:ancestry_column
|
162
|
+
:ancestry_column Column name to store ancestry
|
163
|
+
'ancestry' (default)
|
164
|
+
:ancestry_format Format for ancestry column (see Ancestry Formats section):
|
165
|
+
:materialized_path (default) 1/2/3, root nodes ancestry=nil
|
166
|
+
:materialized_path2 (preferred) /1/2/3/, root nodes ancestry=/
|
137
167
|
:orphan_strategy Instruct Ancestry what to do with children of a node that is destroyed:
|
138
168
|
:destroy All children are destroyed as well (default)
|
139
169
|
:rootify The children of the destroyed node become root nodes
|
140
170
|
:restrict An AncestryException is raised if any children exist
|
141
171
|
:adopt The orphan subtree is added to the parent of the deleted node
|
142
172
|
If the deleted node is Root, then rootify the orphan subtree
|
143
|
-
:cache_depth Cache the depth of each node
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
:primary_key_format
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
173
|
+
:cache_depth Cache the depth of each node (See Depth Cache section)
|
174
|
+
false (default)
|
175
|
+
|
176
|
+
:depth_cache_column column name to store depth cache
|
177
|
+
'ancestry_depth' (default)
|
178
|
+
:primary_key_format regular expression that matches the format of the primary key
|
179
|
+
'[0-9]+' (default) integer ids
|
180
|
+
'[-A-Fa-f0-9]{36}' UUIDs
|
181
|
+
:touch Instruct Ancestry to touch the ancestors of a node when it changes
|
182
|
+
false (default) don't invalide nested key-based caches
|
183
|
+
:counter_cache Whether to create counter cache column accessor.
|
184
|
+
false (default) don't store a counter cache
|
185
|
+
true store counter cache in `children_count`.
|
186
|
+
String name of column to store counter cache.
|
187
|
+
:update_strategy Choose the strategy to update descendants nodes
|
188
|
+
:ruby (default) All descendants are updated using the ruby algorithm.
|
189
|
+
This triggers update callbacks for each descendant node
|
190
|
+
:sql All descendants are updated using a single SQL statement.
|
191
|
+
This strategy does not trigger update callbacks for the descendants.
|
192
|
+
This strategy is available only for PostgreSql implementations
|
193
|
+
|
194
|
+
Legacy configuration using `acts_as_tree` is still available. Ancestry defers to `acts_as_tree` if that gem is installed.
|
155
195
|
|
156
196
|
# (Named) Scopes
|
157
197
|
|
@@ -183,7 +223,7 @@ It is possible thanks to some convenient rails magic to create nodes through the
|
|
183
223
|
|
184
224
|
# Selecting nodes by depth
|
185
225
|
|
186
|
-
With depth caching enabled (see has_ancestry options), an additional five named
|
226
|
+
With depth caching enabled (see [has_ancestry options](#has_ancestry-options)), an additional five named
|
187
227
|
scopes can be used to select nodes by depth:
|
188
228
|
|
189
229
|
before_depth(depth) Return nodes that are less deep than depth (node.depth < depth)
|
@@ -203,16 +243,13 @@ can be fetched directly from the ancestry column without needing a query. Use
|
|
203
243
|
node.descendants(:from_depth => 2, :to_depth => 4)
|
204
244
|
node.subtree.from_depth(10).to_depth(12)
|
205
245
|
|
206
|
-
# STI support
|
207
|
-
|
208
|
-
To use with STI: create a STI inheritance hierarchy and build a tree from the different
|
209
|
-
classes/models. All Ancestry relations that were described above will return nodes of any model type. If
|
210
|
-
you do only want nodes of a specific subclass, a type condition is required.
|
211
|
-
|
212
246
|
# Arrangement
|
213
247
|
|
248
|
+
## `arrange`
|
249
|
+
|
214
250
|
A subtree can be arranged into nested hashes for easy navigation after database retrieval.
|
215
|
-
|
251
|
+
|
252
|
+
The resulting format is a hash of hashes
|
216
253
|
|
217
254
|
```ruby
|
218
255
|
{
|
@@ -225,24 +262,22 @@ A subtree can be arranged into nested hashes for easy navigation after database
|
|
225
262
|
}
|
226
263
|
```
|
227
264
|
|
228
|
-
|
229
|
-
and can take ActiveRecord find options. If you want ordered hashes, pass the order to the method instead of
|
230
|
-
the scope as follows:
|
231
|
-
|
232
|
-
`TreeNode.find_by(:name => 'Crunchy').subtree.arrange(:order => :name)`.
|
265
|
+
There are many ways to call `arrange`:
|
233
266
|
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
Using `ActiveModel` serializers:
|
267
|
+
```ruby
|
268
|
+
TreeNode.find_by(:name => 'Crunchy').subtree.arrange
|
269
|
+
TreeNode.find_by(:name => 'Crunchy').subtree.arrange(:order => :name)
|
270
|
+
```
|
240
271
|
|
241
|
-
`
|
272
|
+
## `arrange_serializable`
|
242
273
|
|
243
|
-
|
274
|
+
If a hash of arrays is preferred, `arrange_serializable` can be used. The results
|
275
|
+
work well with `to_json`.
|
244
276
|
|
245
277
|
```ruby
|
278
|
+
TreeNode.arrange_serializable(:order => :name)
|
279
|
+
# use an active model serializer
|
280
|
+
TreeNode.arrange_serializable { |parent, children| MySerializer.new(parent, children: children) }
|
246
281
|
TreeNode.arrange_serializable do |parent, children|
|
247
282
|
{
|
248
283
|
my_id: parent.id,
|
@@ -254,54 +289,232 @@ end
|
|
254
289
|
# Sorting
|
255
290
|
|
256
291
|
The `sort_by_ancestry` class method: `TreeNode.sort_by_ancestry(array_of_nodes)` can be used
|
257
|
-
to sort an array of nodes as if traversing in preorder. (Note that since
|
292
|
+
to sort an array of nodes as if traversing in preorder. (Note that since materialized path
|
258
293
|
trees do not support ordering within a rank, the order of siblings is
|
259
294
|
dependant upon their original array order.)
|
260
295
|
|
296
|
+
|
297
|
+
# Ancestry Database Column
|
298
|
+
|
299
|
+
## Collation Indexes
|
300
|
+
|
301
|
+
Sorry, using collation or index operator classes makes this a little complicated. The
|
302
|
+
root of the issue is that in order to use indexes, the ancestry column needs to
|
303
|
+
compare strings using ascii rules.
|
304
|
+
|
305
|
+
It is well known that `LIKE '/1/2/%'` will use an index because the wildchard (i.e.: `%`)
|
306
|
+
is on the right hand side of the `LIKE`. While that is true for ascii strings, it is not
|
307
|
+
necessarily true for unicode. Since ancestry only uses ascii characters, telling the database
|
308
|
+
this constraint will optimize the `LIKE` statemens.
|
309
|
+
|
310
|
+
## Collation Sorting
|
311
|
+
|
312
|
+
As of 2018, standard unicode collation ignores punctuation for sorting. This ignores
|
313
|
+
the ancestry delimiter (i.e.: `/`) and returns data in the wrong order. The exception
|
314
|
+
being Postgres on a mac, which ignores proper unicode collation and instead uses
|
315
|
+
ISO-8859-1 ordering (read: ascii sorting).
|
316
|
+
|
317
|
+
Using the proper column storage and indexes will ensure that data is returned from the
|
318
|
+
database in the correct order. It will also ensure that developers on Mac or Windows will
|
319
|
+
get the same results as linux production servers, if that is your setup.
|
320
|
+
|
321
|
+
## Migrating Collation
|
322
|
+
|
323
|
+
If you are reading this and want to alter your table to add collation to an existing column,
|
324
|
+
remember to drop existing indexes on the `ancestry` column and recreate them.
|
325
|
+
|
326
|
+
## ancestry_format materialized_path and nulls
|
327
|
+
|
328
|
+
If you are using the legacy `ancestry_format` of `:materialized_path`, then you need to the
|
329
|
+
collum to allow `nulls`. Change the column create accordingly: `null: true`.
|
330
|
+
|
331
|
+
Chances are, you can ignore this section as you most likely want to use `:materialized_path2`.
|
332
|
+
|
333
|
+
## Postgres Storage Options
|
334
|
+
|
335
|
+
### ascii field collation
|
336
|
+
|
337
|
+
The currently suggested way to create a postgres field is using `'C'` collation:
|
338
|
+
|
339
|
+
```ruby
|
340
|
+
t.string "ancestry", collation: 'C', null: false
|
341
|
+
t.index "ancestry"
|
342
|
+
```
|
343
|
+
|
344
|
+
### ascii index
|
345
|
+
|
346
|
+
If you need to use a standard collation (e.g.: `en_US`), then use an ascii index:
|
347
|
+
|
348
|
+
```ruby
|
349
|
+
t.string "ancestry", null: false
|
350
|
+
t.index "ancestry", opclass: :varchar_pattern_ops
|
351
|
+
```
|
352
|
+
|
353
|
+
This option is mostly there for users who have an existing ancestry column and are more
|
354
|
+
comfortable tweaking indexes rather than altering the ancestry column.
|
355
|
+
|
356
|
+
### binary column
|
357
|
+
|
358
|
+
When the column is binary, the database doesn't convert strings using locales.
|
359
|
+
Rails will convert the strings and send byte arrays to the database.
|
360
|
+
At this time, this option is not suggested. The sql is not as readable, and currently
|
361
|
+
this does not support the `:sql` update_strategy.
|
362
|
+
|
363
|
+
```ruby
|
364
|
+
t.binary "ancestry", limit: 3000, null: false
|
365
|
+
t.index "ancestry"
|
366
|
+
```
|
367
|
+
You may be able to alter the database to gain some readability:
|
368
|
+
|
369
|
+
```SQL
|
370
|
+
ALTER DATABASE dbname SET bytea_output to 'escape';
|
371
|
+
```
|
372
|
+
|
373
|
+
## Mysql Storage options
|
374
|
+
|
375
|
+
### ascii field collation
|
376
|
+
|
377
|
+
The currently suggested way to create a postgres field is using `'C'` collation:
|
378
|
+
|
379
|
+
```ruby
|
380
|
+
t.string "ancestry", collation: 'utf8mb4_bin', null: false
|
381
|
+
t.index "ancestry"
|
382
|
+
```
|
383
|
+
|
384
|
+
### binary collation
|
385
|
+
|
386
|
+
Collation of `binary` acts much the same way as the `binary` column:
|
387
|
+
|
388
|
+
```ruby
|
389
|
+
t.string "ancestry", collate: 'binary', limit: 3000, null: false
|
390
|
+
t.index "ancestry"
|
391
|
+
```
|
392
|
+
|
393
|
+
### binary column
|
394
|
+
|
395
|
+
```ruby
|
396
|
+
t.binary "ancestry", limit: 3000, null: false
|
397
|
+
t.index "ancestry"
|
398
|
+
```
|
399
|
+
|
400
|
+
### ascii character set
|
401
|
+
|
402
|
+
Mysql supports per column character sets. Using a character set of `ascii` will
|
403
|
+
set this up.
|
404
|
+
|
405
|
+
```SQL
|
406
|
+
ALTER TABLE table
|
407
|
+
ADD COLUMN ancestry VARCHAR(2700) CHARACTER SET ascii;
|
408
|
+
```
|
409
|
+
|
410
|
+
# Ancestry Formats
|
411
|
+
|
412
|
+
You can choose from 2 ancestry formats:
|
413
|
+
|
414
|
+
- `:materialized_path` - legacy format (currently the default for backwards compatibility reasons)
|
415
|
+
- `:materialized_path2` - newer format. Use this if it is a new column
|
416
|
+
|
417
|
+
```
|
418
|
+
:materialized_path 1/2/3, root nodes ancestry=nil
|
419
|
+
descendants SQL: ancestry LIKE '1/2/3/%' OR ancestry = '1/2/3'
|
420
|
+
:materialized_path2 /1/2/3/, root nodes ancestry=/
|
421
|
+
descendants SQL: ancestry LIKE '/1/2/3/%'
|
422
|
+
```
|
423
|
+
|
424
|
+
If you are unsure, choose `:materialized_path2`. It allows a not NULL column,
|
425
|
+
faster descenant queries, has one less `OR` statement in the queries, and
|
426
|
+
the path can be formed easily in a database query for added benefits.
|
427
|
+
|
428
|
+
There is more discussion in [Internals](#internals) or [Migrating ancestry format](#migrate-ancestry-format)
|
429
|
+
For migrating from `materialized_path` to `materialized_path2` see [Ancestry Column](#ancestry-column)
|
430
|
+
|
431
|
+
## Migrating Ancestry Format
|
432
|
+
|
433
|
+
To migrate from `materialized_path` to `materialized_path2`:
|
434
|
+
|
435
|
+
```ruby
|
436
|
+
klass = YourModel
|
437
|
+
# set all child nodes
|
438
|
+
klass.where.not(klass.arel_table[klass.ancestry_column].eq(nil)).update_all("#{klass.ancestry_column} = CONCAT('#{klass.ancestry_delimiter}', #{klass.ancestry_column}, '#{klass.ancestry_delimiter}')")
|
439
|
+
# set all root nodes
|
440
|
+
klass.where(klass.arel_table[klass.ancestry_column].eq(nil)).update_all("#{klass.ancestry_column} = '#{klass.ancestry_root}'")
|
441
|
+
|
442
|
+
change_column_null klass.table_name, klass.ancestry_column, false
|
443
|
+
```
|
444
|
+
|
261
445
|
# Migrating from plugin that uses parent_id column
|
262
446
|
|
263
|
-
|
264
|
-
|
265
|
-
easy to migrate from any of these plugins. To do so, use the
|
266
|
-
`build_ancestry_from_parent_ids!` method on your ancestry model.
|
447
|
+
It should be relatively simple to migrating from a plugin that uses a `parent_id`
|
448
|
+
column, (e.g.: `awesome_nested_set`, `better_nested_set`, `acts_as_nested_set`).
|
267
449
|
|
268
|
-
|
269
|
-
|
450
|
+
When running the installation steps, also remove the old gem from your `Gemfile`,
|
451
|
+
and remove the old gem's macros from the model.
|
270
452
|
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
453
|
+
Then populate the `ancestry` column from rails console:
|
454
|
+
|
455
|
+
```ruby
|
456
|
+
Model.build_ancestry_from_parent_ids!
|
457
|
+
# Model.rebuild_depth_cache!
|
458
|
+
Model.check_ancestry_integrity!
|
459
|
+
```
|
277
460
|
|
461
|
+
It is time to run your code. Most tree methods should work fine with ancestry
|
462
|
+
and hopefully your tests only require a few minor tweaks to get up and runnnig.
|
278
463
|
|
279
|
-
|
280
|
-
* See 'Installation' for more info on installing and configuring gems
|
464
|
+
Once you are happy with how your app is running, remove the old `parent_id` column:
|
281
465
|
|
466
|
+
```bash
|
467
|
+
$ rails g migration remove_parent_id_from_[table]
|
468
|
+
```
|
282
469
|
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
470
|
+
```ruby
|
471
|
+
class RemoveParentIdFromToTable < ActiveRecord::Migration[6.1]
|
472
|
+
def change
|
473
|
+
remove_column "table", "parent_id", type: :integer
|
474
|
+
end
|
475
|
+
end
|
476
|
+
```
|
287
477
|
|
478
|
+
```bash
|
479
|
+
$ rake db:migrate
|
480
|
+
```
|
288
481
|
|
289
|
-
|
290
|
-
* In rails console: **[model].build_ancestry_from_parent_ids!**
|
291
|
-
* Make sure it worked ok: **[model].check_ancestry_integrity!**
|
482
|
+
# Depth cache
|
292
483
|
|
484
|
+
## Depth Cache Migration
|
293
485
|
|
294
|
-
|
295
|
-
* Most tree calls will probably work fine with ancestry
|
296
|
-
* Others must be changed or proxied
|
297
|
-
* Check if all your data is intact and all tests pass
|
486
|
+
To add depth_caching to an existing model:
|
298
487
|
|
488
|
+
## Add column
|
299
489
|
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
490
|
+
```ruby
|
491
|
+
class AddDepthCachToTable < ActiveRecord::Migration[6.1]
|
492
|
+
def change
|
493
|
+
change_table(:table) do |t|
|
494
|
+
t.integer "ancestry_depth", default: 0
|
495
|
+
end
|
496
|
+
end
|
497
|
+
end
|
498
|
+
```
|
499
|
+
|
500
|
+
## Add ancestry to your model
|
501
|
+
|
502
|
+
```ruby
|
503
|
+
# app/models/[model.rb]
|
504
|
+
|
505
|
+
class [Model] < ActiveRecord::Base
|
506
|
+
has_ancestry depth_cache: true
|
507
|
+
end
|
508
|
+
```
|
509
|
+
|
510
|
+
## Update existing values
|
511
|
+
|
512
|
+
Add a custom script or run from rails console.
|
513
|
+
Some use migrations, but that can make the migration suite fragile. The command of interest is:
|
514
|
+
|
515
|
+
```ruby
|
516
|
+
Model.rebuild_depth_cache!
|
517
|
+
```
|
305
518
|
|
306
519
|
# Running Tests
|
307
520
|
|
@@ -317,26 +530,6 @@ appraisal rake test
|
|
317
530
|
appraisal sqlite3-ar-50 rake test
|
318
531
|
```
|
319
532
|
|
320
|
-
# Internals
|
321
|
-
|
322
|
-
Ancestry stores a path from the root to the parent for every node.
|
323
|
-
This is a variation on the materialised path database pattern.
|
324
|
-
It allows Ancestry to fetch any relation (siblings,
|
325
|
-
descendants, etc.) in a single SQL query without the complicated algorithms
|
326
|
-
and incomprehensibility associated with left and right values. Additionally,
|
327
|
-
any inserts, deletes and updates only affect nodes within the affected node's
|
328
|
-
own subtree.
|
329
|
-
|
330
|
-
In the example above, the `ancestry` column is created as a `string`. This puts a
|
331
|
-
limitation on the depth of the tree of about 40 or 50 levels. To increase the
|
332
|
-
maximum depth of the tree, increase the size of the `string` or use `text` to
|
333
|
-
remove the limitation entirely. Changing it to a text will however decrease
|
334
|
-
performance because an index cannot be put on the column in that case.
|
335
|
-
|
336
|
-
The materialised path pattern requires Ancestry to use a 'like' condition in
|
337
|
-
order to fetch descendants. The wild character (`%`) is on the right of the
|
338
|
-
query, so indexes should be used.
|
339
|
-
|
340
533
|
# Contributing and license
|
341
534
|
|
342
535
|
Question? Bug report? Faulty/incomplete documentation? Feature request? Please
|
@@ -46,8 +46,10 @@ module Ancestry
|
|
46
46
|
end
|
47
47
|
end
|
48
48
|
|
49
|
-
#
|
50
|
-
#
|
49
|
+
# arranges array of nodes to a hierarchical hash
|
50
|
+
#
|
51
|
+
# @param nodes [Array[Node]] nodes to be arranged
|
52
|
+
# @returns Hash{Node => {Node => {}, Node => {}}}
|
51
53
|
# If a node's parent is not included, the node will be included as if it is a top level node
|
52
54
|
def arrange_nodes(nodes)
|
53
55
|
node_ids = Set.new(nodes.map(&:id))
|
@@ -60,6 +62,18 @@ module Ancestry
|
|
60
62
|
end
|
61
63
|
end
|
62
64
|
|
65
|
+
# convert a hash of the form {node => children} to an array of nodes, child first
|
66
|
+
#
|
67
|
+
# @param arranged [Hash{Node => {Node => {}, Node => {}}}] arranged nodes
|
68
|
+
# @returns [Array[Node]] array of nodes with the parent before the children
|
69
|
+
def flatten_arranged_nodes(arranged, nodes = [])
|
70
|
+
arranged.each do |node, children|
|
71
|
+
nodes << node
|
72
|
+
flatten_arranged_nodes(children, nodes) unless children.empty?
|
73
|
+
end
|
74
|
+
nodes
|
75
|
+
end
|
76
|
+
|
63
77
|
# Arrangement to nested array for serialization
|
64
78
|
# You can also supply your own serialization logic using blocks
|
65
79
|
# also allows you to pass the order just as you can pass it to the arrange method
|
@@ -89,29 +103,21 @@ module Ancestry
|
|
89
103
|
end
|
90
104
|
|
91
105
|
# Pseudo-preordered array of nodes. Children will always follow parents,
|
106
|
+
# This is deterministic unless the parents are missing *and* a sort block is specified
|
92
107
|
def sort_by_ancestry(nodes, &block)
|
93
108
|
arranged = nodes if nodes.is_a?(Hash)
|
94
109
|
|
95
110
|
unless arranged
|
96
111
|
presorted_nodes = nodes.sort do |a, b|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
yield a, b
|
101
|
-
else
|
102
|
-
a_cestry <=> b_cestry
|
103
|
-
end
|
112
|
+
rank = (a.ancestry || ' ') <=> (b.ancestry || ' ')
|
113
|
+
rank = yield(a, b) if rank == 0 && block_given?
|
114
|
+
rank
|
104
115
|
end
|
105
116
|
|
106
117
|
arranged = arrange_nodes(presorted_nodes)
|
107
118
|
end
|
108
119
|
|
109
|
-
arranged
|
110
|
-
node, children = pair
|
111
|
-
sorted_nodes << node
|
112
|
-
sorted_nodes += sort_by_ancestry(children, &block) unless children.blank?
|
113
|
-
sorted_nodes
|
114
|
-
end
|
120
|
+
flatten_arranged_nodes(arranged)
|
115
121
|
end
|
116
122
|
|
117
123
|
# Integrity checking
|
@@ -4,15 +4,25 @@ module Ancestry
|
|
4
4
|
# Check options
|
5
5
|
raise Ancestry::AncestryException.new(I18n.t("ancestry.option_must_be_hash")) unless options.is_a? Hash
|
6
6
|
options.each do |key, value|
|
7
|
-
unless [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column, :touch, :counter_cache, :primary_key_format, :update_strategy, :
|
7
|
+
unless [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column, :touch, :counter_cache, :primary_key_format, :update_strategy, :ancestry_format].include? key
|
8
8
|
raise Ancestry::AncestryException.new(I18n.t("ancestry.unknown_option", key: key.inspect, value: value.inspect))
|
9
9
|
end
|
10
10
|
end
|
11
11
|
|
12
|
+
if options[:ancestry_format].present? && ![:materialized_path, :materialized_path2].include?( options[:ancestry_format] )
|
13
|
+
raise Ancestry::AncestryException.new(I18n.t("ancestry.unknown_format", value: options[:ancestry_format]))
|
14
|
+
end
|
15
|
+
|
12
16
|
# Create ancestry column accessor and set to option or default
|
13
17
|
cattr_accessor :ancestry_column
|
14
18
|
self.ancestry_column = options[:ancestry_column] || :ancestry
|
15
19
|
|
20
|
+
cattr_accessor :ancestry_primary_key_format
|
21
|
+
self.ancestry_primary_key_format = options[:primary_key_format].presence || Ancestry.default_primary_key_format
|
22
|
+
|
23
|
+
cattr_accessor :ancestry_delimiter
|
24
|
+
self.ancestry_delimiter = '/'
|
25
|
+
|
16
26
|
# Save self as base class (for STI)
|
17
27
|
cattr_accessor :ancestry_base_class
|
18
28
|
self.ancestry_base_class = self
|
@@ -27,14 +37,19 @@ module Ancestry
|
|
27
37
|
# Include dynamic class methods
|
28
38
|
extend Ancestry::ClassMethods
|
29
39
|
|
30
|
-
|
31
|
-
|
40
|
+
cattr_accessor :ancestry_format
|
41
|
+
self.ancestry_format = options[:ancestry_format] || Ancestry.default_ancestry_format
|
42
|
+
|
43
|
+
if ancestry_format == :materialized_path2
|
32
44
|
extend Ancestry::MaterializedPath2
|
33
45
|
else
|
34
|
-
validates_format_of self.ancestry_column, :with => derive_materialized_pattern(options[:primary_key_format]), :allow_nil => true
|
35
46
|
extend Ancestry::MaterializedPath
|
36
47
|
end
|
37
48
|
|
49
|
+
attribute self.ancestry_column, default: self.ancestry_root
|
50
|
+
|
51
|
+
validates self.ancestry_column, ancestry_validation_options
|
52
|
+
|
38
53
|
update_strategy = options[:update_strategy] || Ancestry.default_update_strategy
|
39
54
|
include Ancestry::MaterializedPathPg if update_strategy == :sql
|
40
55
|
|
@@ -45,8 +60,8 @@ module Ancestry
|
|
45
60
|
# Validate that the ancestor ids don't include own id
|
46
61
|
validate :ancestry_exclude_self
|
47
62
|
|
48
|
-
# Update descendants with new ancestry
|
49
|
-
|
63
|
+
# Update descendants with new ancestry after update
|
64
|
+
after_update :update_descendants_with_new_ancestry
|
50
65
|
|
51
66
|
# Apply orphan strategy before destroy
|
52
67
|
before_destroy :apply_orphan_strategy
|
@@ -68,12 +83,7 @@ module Ancestry
|
|
68
83
|
# Create counter cache column accessor and set to option or default
|
69
84
|
if options[:counter_cache]
|
70
85
|
cattr_accessor :counter_cache_column
|
71
|
-
|
72
|
-
if options[:counter_cache] == true
|
73
|
-
self.counter_cache_column = :children_count
|
74
|
-
else
|
75
|
-
self.counter_cache_column = options[:counter_cache]
|
76
|
-
end
|
86
|
+
self.counter_cache_column = options[:counter_cache] == true ? 'children_count' : options[:counter_cache].to_s
|
77
87
|
|
78
88
|
after_create :increase_parent_counter_cache, if: :has_parent?
|
79
89
|
after_destroy :decrease_parent_counter_cache, if: :has_parent?
|
@@ -99,31 +109,10 @@ module Ancestry
|
|
99
109
|
return super if defined?(super)
|
100
110
|
has_ancestry(*args)
|
101
111
|
end
|
102
|
-
|
103
|
-
private
|
104
|
-
|
105
|
-
def derive_materialized_pattern(primary_key_format, delimiter = '/')
|
106
|
-
primary_key_format ||= '[0-9]+'
|
107
|
-
|
108
|
-
if primary_key_format.to_s.include?('\A')
|
109
|
-
primary_key_format
|
110
|
-
else
|
111
|
-
/\A#{primary_key_format}(#{delimiter}#{primary_key_format})*\Z/
|
112
|
-
end
|
113
|
-
end
|
114
|
-
|
115
|
-
def derive_materialized2_pattern(primary_key_format, delimiter = '/')
|
116
|
-
primary_key_format ||= '[0-9]+'
|
117
|
-
|
118
|
-
if primary_key_format.to_s.include?('\A')
|
119
|
-
primary_key_format
|
120
|
-
else
|
121
|
-
/\A#{delimiter}(#{primary_key_format}#{delimiter})*\Z/
|
122
|
-
end
|
123
|
-
end
|
124
112
|
end
|
125
113
|
end
|
126
114
|
|
115
|
+
require 'active_support'
|
127
116
|
ActiveSupport.on_load :active_record do
|
128
117
|
extend Ancestry::HasAncestry
|
129
118
|
end
|
@@ -5,15 +5,15 @@ module Ancestry
|
|
5
5
|
errors.add(:base, I18n.t("ancestry.exclude_self", class_name: self.class.name.humanize)) if ancestor_ids.include? self.id
|
6
6
|
end
|
7
7
|
|
8
|
-
# Update descendants with new ancestry (
|
8
|
+
# Update descendants with new ancestry (after update)
|
9
9
|
def update_descendants_with_new_ancestry
|
10
10
|
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
|
11
11
|
if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestor_ids?
|
12
12
|
# ... for each descendant ...
|
13
|
-
|
13
|
+
unscoped_descendants_before_save.each do |descendant|
|
14
14
|
# ... replace old ancestry with new ancestry
|
15
15
|
descendant.without_ancestry_callbacks do
|
16
|
-
new_ancestor_ids = path_ids + (descendant.ancestor_ids -
|
16
|
+
new_ancestor_ids = path_ids + (descendant.ancestor_ids - path_ids_before_last_save)
|
17
17
|
descendant.update_attribute(:ancestor_ids, new_ancestor_ids)
|
18
18
|
end
|
19
19
|
end
|
@@ -62,7 +62,7 @@ module Ancestry
|
|
62
62
|
|
63
63
|
# Counter Cache
|
64
64
|
def increase_parent_counter_cache
|
65
|
-
self.ancestry_base_class.increment_counter
|
65
|
+
self.ancestry_base_class.increment_counter counter_cache_column, parent_id
|
66
66
|
end
|
67
67
|
|
68
68
|
def decrease_parent_counter_cache
|
@@ -74,7 +74,7 @@ module Ancestry
|
|
74
74
|
return if defined?(@_trigger_destroy_callback) && !@_trigger_destroy_callback
|
75
75
|
return if ancestry_callbacks_disabled?
|
76
76
|
|
77
|
-
self.ancestry_base_class.decrement_counter
|
77
|
+
self.ancestry_base_class.decrement_counter counter_cache_column, parent_id
|
78
78
|
end
|
79
79
|
|
80
80
|
def update_parent_counter_cache
|
@@ -83,14 +83,10 @@ module Ancestry
|
|
83
83
|
return unless changed
|
84
84
|
|
85
85
|
if parent_id_was = parent_id_before_last_save
|
86
|
-
self.ancestry_base_class.decrement_counter
|
86
|
+
self.ancestry_base_class.decrement_counter counter_cache_column, parent_id_was
|
87
87
|
end
|
88
88
|
|
89
|
-
parent_id &&
|
90
|
-
end
|
91
|
-
|
92
|
-
def _counter_cache_column
|
93
|
-
self.ancestry_base_class.counter_cache_column.to_s
|
89
|
+
parent_id && increase_parent_counter_cache
|
94
90
|
end
|
95
91
|
|
96
92
|
# Ancestors
|
@@ -133,8 +129,8 @@ module Ancestry
|
|
133
129
|
ancestor_ids + [id]
|
134
130
|
end
|
135
131
|
|
136
|
-
def
|
137
|
-
|
132
|
+
def path_ids_before_last_save
|
133
|
+
ancestor_ids_before_last_save + [id]
|
138
134
|
end
|
139
135
|
|
140
136
|
def path depth_options = {}
|
@@ -190,7 +186,7 @@ module Ancestry
|
|
190
186
|
|
191
187
|
def root
|
192
188
|
if has_parent?
|
193
|
-
unscoped_where { |scope| scope.find_by(
|
189
|
+
unscoped_where { |scope| scope.find_by(scope.primary_key => root_id) } || self
|
194
190
|
else
|
195
191
|
self
|
196
192
|
end
|
@@ -312,10 +308,16 @@ module Ancestry
|
|
312
308
|
end
|
313
309
|
end
|
314
310
|
|
311
|
+
def unscoped_descendants_before_save
|
312
|
+
unscoped_where do |scope|
|
313
|
+
scope.where self.ancestry_base_class.descendant_before_save_conditions(self)
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
315
317
|
# works with after save context (hence before_last_save)
|
316
318
|
def unscoped_current_and_previous_ancestors
|
317
319
|
unscoped_where do |scope|
|
318
|
-
scope.where
|
320
|
+
scope.where scope.primary_key => (ancestor_ids + ancestor_ids_before_last_save).uniq
|
319
321
|
end
|
320
322
|
end
|
321
323
|
|
data/lib/ancestry/locales/en.yml
CHANGED
@@ -9,6 +9,7 @@ en:
|
|
9
9
|
|
10
10
|
option_must_be_hash: "Options for has_ancestry must be in a hash."
|
11
11
|
unknown_option: "Unknown option for has_ancestry: %{key} => %{value}."
|
12
|
+
unknown_format: "Unknown ancestry format: %{value}."
|
12
13
|
named_scope_depth_cache: "Named scope '%{scope_name}' is only available when depth caching is enabled."
|
13
14
|
|
14
15
|
exclude_self: "%{class_name} cannot be a descendant of itself."
|
@@ -3,11 +3,6 @@ module Ancestry
|
|
3
3
|
# root a=nil,id=1 children=id,id/% == 1, 1/%
|
4
4
|
# 3: a=1/2,id=3 children=a/id,a/id/% == 1/2/3, 1/2/3/%
|
5
5
|
module MaterializedPath
|
6
|
-
BEFORE_LAST_SAVE_SUFFIX = '_before_last_save'.freeze
|
7
|
-
IN_DATABASE_SUFFIX = '_in_database'.freeze
|
8
|
-
ANCESTRY_DELIMITER='/'.freeze
|
9
|
-
ROOT=nil
|
10
|
-
|
11
6
|
def self.extended(base)
|
12
7
|
base.send(:include, InstanceMethods)
|
13
8
|
end
|
@@ -17,7 +12,7 @@ module Ancestry
|
|
17
12
|
end
|
18
13
|
|
19
14
|
def roots
|
20
|
-
where(arel_table[ancestry_column].eq(
|
15
|
+
where(arel_table[ancestry_column].eq(ancestry_root))
|
21
16
|
end
|
22
17
|
|
23
18
|
def ancestors_of(object)
|
@@ -42,19 +37,26 @@ module Ancestry
|
|
42
37
|
def indirects_of(object)
|
43
38
|
t = arel_table
|
44
39
|
node = to_node(object)
|
45
|
-
where(t[ancestry_column].matches("#{node.child_ancestry}#{
|
40
|
+
where(t[ancestry_column].matches("#{node.child_ancestry}#{ancestry_delimiter}%", nil, true))
|
46
41
|
end
|
47
42
|
|
48
43
|
def descendants_of(object)
|
49
|
-
|
50
|
-
indirects_of(node).or(children_of(node))
|
44
|
+
where(descendant_conditions(object))
|
51
45
|
end
|
52
46
|
|
53
|
-
|
54
|
-
def descendant_conditions(object)
|
47
|
+
def descendants_by_ancestry(ancestry)
|
55
48
|
t = arel_table
|
49
|
+
t[ancestry_column].matches("#{ancestry}#{ancestry_delimiter}%", nil, true).or(t[ancestry_column].eq(ancestry))
|
50
|
+
end
|
51
|
+
|
52
|
+
def descendant_conditions(object)
|
56
53
|
node = to_node(object)
|
57
|
-
|
54
|
+
descendants_by_ancestry( node.child_ancestry )
|
55
|
+
end
|
56
|
+
|
57
|
+
def descendant_before_save_conditions(object)
|
58
|
+
node = to_node(object)
|
59
|
+
descendants_by_ancestry( node.child_ancestry_before_save )
|
58
60
|
end
|
59
61
|
|
60
62
|
def subtree_of(object)
|
@@ -86,16 +88,36 @@ module Ancestry
|
|
86
88
|
ordered_by_ancestry(order)
|
87
89
|
end
|
88
90
|
|
91
|
+
def ancestry_root
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def ancestry_validation_options
|
98
|
+
{
|
99
|
+
format: { with: ancestry_format_regexp },
|
100
|
+
allow_nil: ancestry_nil_allowed?
|
101
|
+
}
|
102
|
+
end
|
103
|
+
|
104
|
+
def ancestry_nil_allowed?
|
105
|
+
true
|
106
|
+
end
|
107
|
+
|
108
|
+
def ancestry_format_regexp
|
109
|
+
/\A#{ancestry_primary_key_format}(#{Regexp.escape(ancestry_delimiter)}#{ancestry_primary_key_format})*\z/.freeze
|
110
|
+
end
|
111
|
+
|
89
112
|
module InstanceMethods
|
90
113
|
# optimization - better to go directly to column and avoid parsing
|
91
114
|
def ancestors?
|
92
|
-
read_attribute(self.ancestry_base_class.ancestry_column) !=
|
115
|
+
read_attribute(self.ancestry_base_class.ancestry_column) != self.ancestry_base_class.ancestry_root
|
93
116
|
end
|
94
117
|
alias :has_parent? :ancestors?
|
95
118
|
|
96
119
|
def ancestor_ids=(value)
|
97
|
-
|
98
|
-
value.present? ? write_attribute(col, generate_ancestry(value)) : write_attribute(col, ROOT)
|
120
|
+
write_attribute(self.ancestry_base_class.ancestry_column, generate_ancestry(value))
|
99
121
|
end
|
100
122
|
|
101
123
|
def ancestor_ids
|
@@ -103,18 +125,19 @@ module Ancestry
|
|
103
125
|
end
|
104
126
|
|
105
127
|
def ancestor_ids_in_database
|
106
|
-
parse_ancestry_column(
|
128
|
+
parse_ancestry_column(attribute_in_database(self.class.ancestry_column))
|
107
129
|
end
|
108
130
|
|
109
131
|
def ancestor_ids_before_last_save
|
110
|
-
parse_ancestry_column(
|
132
|
+
parse_ancestry_column(attribute_before_last_save(self.ancestry_base_class.ancestry_column))
|
111
133
|
end
|
112
134
|
|
113
|
-
def
|
114
|
-
|
115
|
-
|
135
|
+
def parent_id_in_database
|
136
|
+
parse_ancestry_column(attribute_in_database(self.class.ancestry_column)).last
|
137
|
+
end
|
116
138
|
|
117
|
-
|
139
|
+
def parent_id_before_last_save
|
140
|
+
parse_ancestry_column(attribute_before_last_save(self.ancestry_base_class.ancestry_column)).last
|
118
141
|
end
|
119
142
|
|
120
143
|
# optimization - better to go directly to column and avoid parsing
|
@@ -128,18 +151,27 @@ module Ancestry
|
|
128
151
|
def child_ancestry
|
129
152
|
# New records cannot have children
|
130
153
|
raise Ancestry::AncestryException.new(I18n.t("ancestry.no_child_for_new_record")) if new_record?
|
131
|
-
|
132
|
-
|
154
|
+
[attribute_in_database(self.ancestry_base_class.ancestry_column), id].compact.join(self.ancestry_base_class.ancestry_delimiter)
|
155
|
+
end
|
156
|
+
|
157
|
+
def child_ancestry_before_save
|
158
|
+
# New records cannot have children
|
159
|
+
raise Ancestry::AncestryException.new(I18n.t("ancestry.no_child_for_new_record")) if new_record?
|
160
|
+
[attribute_before_last_save(self.ancestry_base_class.ancestry_column), id].compact.join(self.ancestry_base_class.ancestry_delimiter)
|
133
161
|
end
|
134
162
|
|
135
163
|
def parse_ancestry_column(obj)
|
136
|
-
return [] if obj ==
|
137
|
-
obj_ids = obj.split(
|
164
|
+
return [] if obj.nil? || obj == self.ancestry_base_class.ancestry_root
|
165
|
+
obj_ids = obj.split(self.ancestry_base_class.ancestry_delimiter).delete_if(&:blank?)
|
138
166
|
self.class.primary_key_is_an_integer? ? obj_ids.map!(&:to_i) : obj_ids
|
139
167
|
end
|
140
168
|
|
141
169
|
def generate_ancestry(ancestor_ids)
|
142
|
-
ancestor_ids.
|
170
|
+
if ancestor_ids.present? && ancestor_ids.any?
|
171
|
+
ancestor_ids.join(self.ancestry_base_class.ancestry_delimiter)
|
172
|
+
else
|
173
|
+
self.ancestry_base_class.ancestry_root
|
174
|
+
end
|
143
175
|
end
|
144
176
|
end
|
145
177
|
end
|
@@ -1,53 +1,62 @@
|
|
1
1
|
module Ancestry
|
2
2
|
# store ancestry as /grandparent_id/parent_id/
|
3
|
-
# root: a=/,id=1 children
|
4
|
-
# 3: a=/1/2/,id=3 children
|
5
|
-
module MaterializedPath2
|
6
|
-
|
7
|
-
t = arel_table
|
8
|
-
node = to_node(object)
|
9
|
-
where(t[ancestry_column].matches("#{node.child_ancestry}%#{ANCESTRY_DELIMITER}%", nil, true))
|
10
|
-
end
|
3
|
+
# root: a=/,id=1 children=#{a}#{id}/% == /1/%
|
4
|
+
# 3: a=/1/2/,id=3 children=#{a}#{id}/% == /1/2/3/%
|
5
|
+
module MaterializedPath2
|
6
|
+
include MaterializedPath
|
11
7
|
|
12
|
-
def
|
13
|
-
|
14
|
-
|
15
|
-
where(descendant_conditions(node).or(t[primary_key].eq(node.id)))
|
8
|
+
def self.extended(base)
|
9
|
+
base.send(:include, MaterializedPath::InstanceMethods)
|
10
|
+
base.send(:include, InstanceMethods)
|
16
11
|
end
|
17
12
|
|
18
|
-
def
|
13
|
+
def indirects_of(object)
|
19
14
|
t = arel_table
|
20
15
|
node = to_node(object)
|
21
|
-
where(t[ancestry_column].
|
16
|
+
where(t[ancestry_column].matches("#{node.child_ancestry}%#{ancestry_delimiter}%", nil, true))
|
22
17
|
end
|
23
18
|
|
24
19
|
def ordered_by_ancestry(order = nil)
|
25
20
|
reorder(Arel::Nodes::Ascending.new(arel_table[ancestry_column]), order)
|
26
21
|
end
|
27
22
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
23
|
+
def descendants_by_ancestry(ancestry)
|
24
|
+
arel_table[ancestry_column].matches("#{ancestry}%", nil, true)
|
25
|
+
end
|
26
|
+
|
27
|
+
def ancestry_root
|
28
|
+
ancestry_delimiter
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def ancestry_nil_allowed?
|
34
|
+
false
|
35
|
+
end
|
36
|
+
|
37
|
+
def ancestry_format_regexp
|
38
|
+
/\A#{Regexp.escape(ancestry_delimiter)}(#{ancestry_primary_key_format}#{Regexp.escape(ancestry_delimiter)})*\z/.freeze
|
33
39
|
end
|
34
40
|
|
35
41
|
module InstanceMethods
|
36
42
|
def child_ancestry
|
37
43
|
# New records cannot have children
|
38
|
-
raise Ancestry::AncestryException.new(
|
39
|
-
|
40
|
-
"#{path_was}#{id}#{ANCESTRY_DELIMITER}"
|
44
|
+
raise Ancestry::AncestryException.new(I18n.t("ancestry.no_child_for_new_record")) if new_record?
|
45
|
+
"#{attribute_in_database(self.ancestry_base_class.ancestry_column)}#{id}#{self.ancestry_base_class.ancestry_delimiter}"
|
41
46
|
end
|
42
47
|
|
43
|
-
def
|
44
|
-
|
45
|
-
|
46
|
-
self.
|
48
|
+
def child_ancestry_before_save
|
49
|
+
# New records cannot have children
|
50
|
+
raise Ancestry::AncestryException.new(I18n.t("ancestry.no_child_for_new_record")) if new_record?
|
51
|
+
"#{attribute_before_last_save(self.ancestry_base_class.ancestry_column)}#{id}#{self.ancestry_base_class.ancestry_delimiter}"
|
47
52
|
end
|
48
53
|
|
49
54
|
def generate_ancestry(ancestor_ids)
|
50
|
-
|
55
|
+
if ancestor_ids.present? && ancestor_ids.any?
|
56
|
+
"#{self.ancestry_base_class.ancestry_delimiter}#{ancestor_ids.join(self.ancestry_base_class.ancestry_delimiter)}#{self.ancestry_base_class.ancestry_delimiter}"
|
57
|
+
else
|
58
|
+
self.ancestry_base_class.ancestry_root
|
59
|
+
end
|
51
60
|
end
|
52
61
|
end
|
53
62
|
end
|
@@ -1,22 +1,22 @@
|
|
1
1
|
module Ancestry
|
2
2
|
module MaterializedPathPg
|
3
|
-
# Update descendants with new ancestry (
|
3
|
+
# Update descendants with new ancestry (after update)
|
4
4
|
def update_descendants_with_new_ancestry
|
5
5
|
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
|
6
6
|
if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestor_ids?
|
7
7
|
ancestry_column = ancestry_base_class.ancestry_column
|
8
|
-
old_ancestry =
|
9
|
-
new_ancestry = path_ids
|
8
|
+
old_ancestry = generate_ancestry( path_ids_before_last_save )
|
9
|
+
new_ancestry = generate_ancestry( path_ids )
|
10
10
|
update_clause = [
|
11
|
-
"#{ancestry_column} = regexp_replace(#{ancestry_column}, '^#{old_ancestry}', '#{new_ancestry}')"
|
11
|
+
"#{ancestry_column} = regexp_replace(#{ancestry_column}, '^#{Regexp.escape(old_ancestry)}', '#{new_ancestry}')"
|
12
12
|
]
|
13
13
|
|
14
14
|
if ancestry_base_class.respond_to?(:depth_cache_column) && respond_to?(ancestry_base_class.depth_cache_column)
|
15
15
|
depth_cache_column = ancestry_base_class.depth_cache_column.to_s
|
16
|
-
update_clause << "#{depth_cache_column} = length(regexp_replace(regexp_replace(ancestry, '^#{old_ancestry}', '#{new_ancestry}'), '
|
16
|
+
update_clause << "#{depth_cache_column} = length(regexp_replace(regexp_replace(ancestry, '^#{Regexp.escape(old_ancestry)}', '#{new_ancestry}'), '[^#{ancestry_base_class.ancestry_delimiter}]', '', 'g')) #{ancestry_base_class.ancestry_format == :materialized_path2 ? '-' : '+'} 1"
|
17
17
|
end
|
18
18
|
|
19
|
-
|
19
|
+
unscoped_descendants_before_save.update_all update_clause.join(', ')
|
20
20
|
end
|
21
21
|
end
|
22
22
|
end
|
data/lib/ancestry/version.rb
CHANGED
data/lib/ancestry.rb
CHANGED
@@ -4,6 +4,7 @@ require_relative 'ancestry/instance_methods'
|
|
4
4
|
require_relative 'ancestry/exceptions'
|
5
5
|
require_relative 'ancestry/has_ancestry'
|
6
6
|
require_relative 'ancestry/materialized_path'
|
7
|
+
require_relative 'ancestry/materialized_path2'
|
7
8
|
require_relative 'ancestry/materialized_path_pg'
|
8
9
|
|
9
10
|
I18n.load_path += Dir[File.join(File.expand_path(File.dirname(__FILE__)),
|
@@ -11,6 +12,8 @@ I18n.load_path += Dir[File.join(File.expand_path(File.dirname(__FILE__)),
|
|
11
12
|
|
12
13
|
module Ancestry
|
13
14
|
@@default_update_strategy = :ruby
|
15
|
+
@@default_ancestry_format = :materialized_path
|
16
|
+
@@default_primary_key_format = '[0-9]+'
|
14
17
|
|
15
18
|
# @!default_update_strategy
|
16
19
|
# @return [Symbol] the default strategy for updating ancestry
|
@@ -26,7 +29,6 @@ module Ancestry
|
|
26
29
|
#
|
27
30
|
# Child records are updated in sql and callbacks will not get called.
|
28
31
|
# Associated records in memory will have the wrong ancestry value
|
29
|
-
|
30
32
|
def self.default_update_strategy
|
31
33
|
@@default_update_strategy
|
32
34
|
end
|
@@ -34,4 +36,39 @@ module Ancestry
|
|
34
36
|
def self.default_update_strategy=(value)
|
35
37
|
@@default_update_strategy = value
|
36
38
|
end
|
39
|
+
|
40
|
+
# @!default_ancestry_format
|
41
|
+
# @return [Symbol] the default strategy for updating ancestry
|
42
|
+
#
|
43
|
+
# The value changes the default way that ancestry is stored in the database
|
44
|
+
#
|
45
|
+
# :materialized_path (default and legacy)
|
46
|
+
#
|
47
|
+
# Ancestry is of the form null (for no ancestors) and 1/2/ for children
|
48
|
+
#
|
49
|
+
# :materialized_path2 (preferred)
|
50
|
+
#
|
51
|
+
# Ancestry is of the form '/' (for no ancestors) and '/1/2/' for children
|
52
|
+
def self.default_ancestry_format
|
53
|
+
@@default_ancestry_format
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.default_ancestry_format=(value)
|
57
|
+
@@default_ancestry_format = value
|
58
|
+
end
|
59
|
+
|
60
|
+
# @!default_primary_key_format
|
61
|
+
# @return [Symbol] the regular expression representing the primary key
|
62
|
+
#
|
63
|
+
# The value represents the way the id looks for validation
|
64
|
+
#
|
65
|
+
# '[0-9]+' (default) for integer ids
|
66
|
+
# '[-A-Fa-f0-9]{36}' for uuids (though you can find other regular expressions)
|
67
|
+
def self.default_primary_key_format
|
68
|
+
@@default_primary_key_format
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.default_primary_key_format=(value)
|
72
|
+
@@default_primary_key_format = value
|
73
|
+
end
|
37
74
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ancestry
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 4.
|
4
|
+
version: 4.3.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stefan Kroes
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2023-03-20 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|
@@ -145,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
145
145
|
- !ruby/object:Gem::Version
|
146
146
|
version: '0'
|
147
147
|
requirements: []
|
148
|
-
rubygems_version: 3.
|
148
|
+
rubygems_version: 3.2.32
|
149
149
|
signing_key:
|
150
150
|
specification_version: 4
|
151
151
|
summary: Organize ActiveRecord model into a tree structure
|