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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82bcd1895093ab9b569806ef05fc307fc2ca9ab53ffeb09199bd66fe3672ecfb
4
- data.tar.gz: fe7d0d356641658be2953309c27dd28a42e81ed569690cc5e26b2721b1aa6a37
3
+ metadata.gz: 1628363389f64272d25675e727b155ba36c221404a95fc7d7d41de12b2185614
4
+ data.tar.gz: 2a76d777929b18efa86bde34b969f11ab58c3f44424e82c97e69ef8f348d1c96
5
5
  SHA512:
6
- metadata.gz: f33384a1114d865662be0133c532249be0e7ea438a66e469947b3aaba3172378ae50309e02c4f219c1f161281a9e060bff6087a496850c26351fd83348627e79
7
- data.tar.gz: 2a6cb3fb28f6c9a8228b522c590c2125c4e31056a97ac6a6cda96ea4152725fe89e375b59c894091ebcd3d5be36ad18b348c6691211bc6bea2b0a49502c9f423
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.2.0...HEAD
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
- Ancestry is a gem that allows the records of a Ruby on Rails
6
- ActiveRecord model to be organised as a tree structure (or hierarchy). It employs
7
- the materialised path pattern and exposes all the standard tree structure
8
- relations (ancestors, parent, root, children, siblings, descendants), allowing all
9
- of them to be fetched in a single SQL query. Additional features include STI
10
- support, scopes, depth caching, depth constraints, easy migration from older
11
- gems, integrity checking, integrity restoration, arrangement of
12
- (sub)trees into hashes, and various strategies for dealing with orphaned
13
- records.
14
-
15
- NOTE:
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.0 only supports rails 5.0 and higher
50
+ - Ancestry 4.x only supports rails 5.2 and higher
20
51
 
21
52
  # Installation
22
53
 
23
- Follow these simple steps to apply Ancestry to any ActiveRecord model:
54
+ Follow these steps to apply Ancestry to any ActiveRecord model:
24
55
 
25
- ## Install
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 add_ancestry_to_[table] ancestry:string:index
47
- # or use different column name of your choosing. e.g. name:
48
- # rails g migration add_name_to_[people] name:string:index
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
- * Migrate your database:
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
- Depending upon your comfort with databases, you may want to create the column
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
- If you opt out of this, and are trying to run tests on postgres, you may need to
65
- set the environment variable `COLLATE_SYMBOLS=false`. Sorry to say that a discussion
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
- NOTE: A Btree index (as is recommended) has a limitaton of 2704 characters for the ancestry column. This means you can't have an tree with a depth that is too great (~> 900 items at most).
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 # or alternatively as below:
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 the parent attribute to organise your records into a tree. If you
95
- have the id of the record you want to use as a parent and don't want to fetch
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
- `TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky')`.
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
- # `has_ancestry` options
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 Pass in a symbol to store ancestry in a different 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 in the 'ancestry_depth' column (default: false)
144
- If you turn depth_caching on for an existing model:
145
- - Migrate: add_column [table], :ancestry_depth, :integer, :default => 0
146
- - Build cache: TreeNode.rebuild_depth_cache!
147
- :depth_cache_column Pass in a symbol to store depth cache in a different column
148
- :primary_key_format Supply a regular expression that matches the format of your primary key
149
- By default, primary keys only match integers ([0-9]+)
150
- :touch Instruct Ancestry to touch the ancestors of a node when it changes, to
151
- invalidate nested key-based caches. (default: false)
152
- :counter_cache Boolean whether to create counter cache column accessor.
153
- Default column name is `children_count`.
154
- Pass symbol to use different column name (default: false)
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
- `TreeNode.arrange` could, for instance, return:
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
- The `arrange` method can work on a scoped class (`TreeNode.find_by(:name => 'Crunchy').subtree.arrange`),
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
- The `arrange_serializable` method returns the arranged nodes as a nested array of hashes. Order
235
- can be passed in the same fashion as to the `arrange` method:
236
- `TreeNode.arrange_serializable(:order => :name)` The result can easily be serialized to json with `to_json`
237
- or other formats. You can also supply your own serialization logic with blocks.
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
- `TreeNode.arrange_serializable { |parent, children| MySerializer.new(parent, children: children) }`.
272
+ ## `arrange_serializable`
242
273
 
243
- Or plain hashes:
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 materialised path
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
- Most current tree plugins use a parent_id column (has_ancestry,
264
- awesome_nested_set, better_nested_set, acts_as_nested_set). With Ancestry it is
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
- <details>
269
- <summary>Details</summary>
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
- 1. Add ancestry column to your table
272
- * Create migration: **rails g migration [add_ancestry_to_](table)
273
- ancestry:string**
274
- * Add index to migration: **add_index [table], :ancestry** (UP) /
275
- **remove_index [table], :ancestry** (DOWN)
276
- * Migrate your database: **rake db:migrate**
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
- 2. Remove old tree gem and add in Ancestry to Gemfile
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
- 3. Change your model
284
- * Remove any macros required by old plugin/gem from
285
- `[app/models/](model).rb`
286
- * Add to `[app/models/](model).rb`: `has_ancestry`
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
- 4. Generate ancestry columns
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
- 5. Change your code
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
- 6. Drop parent_id column:
301
- * Create migration: `rails g migration [remove_parent_id_from_](table)`
302
- * Add to migration: `remove_column [table], :parent_id`
303
- * Migrate your database: `rake db:migrate`
304
- </details>
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
- # Arrange array of nodes into a nested hash of the form
50
- # {node => children}, where children = {} if the node has no children
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
- a_cestry, b_cestry = a.ancestry || '0', b.ancestry || '0'
98
-
99
- if block_given? && a_cestry == b_cestry
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.inject([]) do |sorted_nodes, pair|
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, :strategy].include? key
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
- if options[:strategy] == :materialized_path2
31
- validates_format_of self.ancestry_column, :with => derive_materialized2_pattern(options[:primary_key_format]), :allow_nil => false
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 before save
49
- before_save :update_descendants_with_new_ancestry
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 (before save)
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
- unscoped_descendants.each do |descendant|
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 - path_ids_in_database)
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 _counter_cache_column, parent_id
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 _counter_cache_column, parent_id
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 _counter_cache_column, parent_id_was
86
+ self.ancestry_base_class.decrement_counter counter_cache_column, parent_id_was
87
87
  end
88
88
 
89
- parent_id && self.ancestry_base_class.increment_counter(_counter_cache_column, 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 path_ids_in_database
137
- ancestor_ids_in_database + [id]
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(id: root_id) } || self
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 id: (ancestor_ids + ancestor_ids_before_last_save).uniq
320
+ scope.where scope.primary_key => (ancestor_ids + ancestor_ids_before_last_save).uniq
319
321
  end
320
322
  end
321
323
 
@@ -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(ROOT))
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}#{ANCESTRY_DELIMITER}%", nil, true))
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
- node = to_node(object)
50
- indirects_of(node).or(children_of(node))
44
+ where(descendant_conditions(object))
51
45
  end
52
46
 
53
- # deprecated
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
- t[ancestry_column].matches("#{node.child_ancestry}/%", nil, true).or(t[ancestry_column].eq(node.child_ancestry))
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) != ROOT
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
- col = self.ancestry_base_class.ancestry_column
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(send("#{self.ancestry_base_class.ancestry_column}#{IN_DATABASE_SUFFIX}"))
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(send("#{self.ancestry_base_class.ancestry_column}#{BEFORE_LAST_SAVE_SUFFIX}"))
132
+ parse_ancestry_column(attribute_before_last_save(self.ancestry_base_class.ancestry_column))
111
133
  end
112
134
 
113
- def parent_id_before_last_save
114
- ancestry_was = send("#{self.ancestry_base_class.ancestry_column}#{BEFORE_LAST_SAVE_SUFFIX}")
115
- return if ancestry_was == ROOT
135
+ def parent_id_in_database
136
+ parse_ancestry_column(attribute_in_database(self.class.ancestry_column)).last
137
+ end
116
138
 
117
- parse_ancestry_column(ancestry_was).last
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
- path_was = self.send("#{self.ancestry_base_class.ancestry_column}#{IN_DATABASE_SUFFIX}")
132
- path_was.blank? ? id.to_s : "#{path_was}#{ANCESTRY_DELIMITER}#{id}"
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 == ROOT
137
- obj_ids = obj.split(ANCESTRY_DELIMITER)
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.join(ANCESTRY_DELIMITER)
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=a.id/% == /1/%
4
- # 3: a=/1/2/,id=3 children=a.id/% == /1/2/3/%
5
- module MaterializedPath2 < MaterializedPath
6
- def indirects_of(object)
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 subtree_of(object)
13
- t = arel_table
14
- node = to_node(object)
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 siblings_of(object)
13
+ def indirects_of(object)
19
14
  t = arel_table
20
15
  node = to_node(object)
21
- where(t[ancestry_column].eq(node[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
- # deprecated
29
- def descendant_conditions(object)
30
- t = arel_table
31
- node = to_node(object)
32
- t[ancestry_column].matches("#{node.child_ancestry}%", nil, true)
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('No child ancestry for new record. Save record before performing tree operations.') if new_record?
39
- path_was = self.send("#{self.ancestry_base_class.ancestry_column}#{IN_DATABASE_SUFFIX}")
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 parse_ancestry_column(obj)
44
- return [] if obj == ROOT
45
- obj_ids = obj.split(ANCESTRY_DELIMITER).delete_if(&:blank?)
46
- self.class.primary_key_is_an_integer? ? obj_ids.map!(&:to_i) : obj_ids
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
- "#{ANCESTRY_DELIMITER}#{ancestor_ids.join(ANCESTRY_DELIMITER)}#{ANCESTRY_DELIMITER}"
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 (before save)
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 = path_ids_in_database.join(Ancestry::MaterializedPath::ANCESTRY_DELIMITER)
9
- new_ancestry = path_ids.join(Ancestry::MaterializedPath::ANCESTRY_DELIMITER)
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}'), '\\d', '', 'g')) + 1"
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
- unscoped_descendants.update_all update_clause.join(', ')
19
+ unscoped_descendants_before_save.update_all update_clause.join(', ')
20
20
  end
21
21
  end
22
22
  end
@@ -1,3 +1,3 @@
1
1
  module Ancestry
2
- VERSION = '4.2.0'
2
+ VERSION = '4.3.1'
3
3
  end
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.2.0
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: 2022-06-10 00:00:00.000000000 Z
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.3.7
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