ancestry 4.2.0 → 4.3.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|