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 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