Compare commits

...

286 Commits

Author SHA1 Message Date
Keenan Brock 2f6e6a635b
Merge pull request #694 from a5-stable/specify-concurrent-version
Specify the version of concurrent-ruby due to CI failure
2025-02-12 11:10:41 -05:00
a5-stable 4f1593ca2e specify concurrent version for CI failure in Logger 2025-02-12 10:47:26 +09:00
Keenan Brock e8e0bb1ebb
Merge pull request #691 from kbrock/bin_setup
bin/console for testing
2024-10-23 20:14:57 -04:00
Keenan Brock 9ea121d125
bin/console for testing 2024-10-23 20:13:31 -04:00
Keenan Brock 4d267887f4
Merge pull request #690 from kbrock/test_chatter
reduce test chatter
2024-10-23 20:10:02 -04:00
Keenan Brock 5bcd89c061
require logger 2024-10-23 20:07:08 -04:00
Keenan Brock 9ed9d59078
reduce test chatter 2024-10-23 19:58:37 -04:00
Keenan Brock 6cc93989c0
Merge pull request #688 from kbrock/cops
rubocop fixes
2024-10-23 19:39:45 -04:00
Keenan Brock 98d831b865
rubocop.yml 2024-10-23 19:33:58 -04:00
Keenan Brock 333714cc27
rubocop spacing 2024-10-23 19:33:58 -04:00
Keenan Brock b4f288a7e3
rubocop methods 2024-10-23 19:33:57 -04:00
Keenan Brock cddecd1e42
drop extra self 2024-10-23 19:31:52 -04:00
Keenan Brock 5e65618739
method parens 2024-10-23 19:31:52 -04:00
Keenan Brock 6a3b3fb3a5
use Raise exception, message (vs Raise exception.new() 2024-10-23 19:31:52 -04:00
Keenan Brock faf9029af1
add fronzen string comment 2024-10-23 19:31:51 -04:00
Keenan Brock 78d63a8e10
Merge pull request #670 from kbrock/virtual_depth
Add virtual depth column support
2024-10-23 19:31:21 -04:00
Keenan Brock 541c167938
Add support for virtual depth column
still need to consider whether to keep depth_cache_column as deprecated
2024-10-22 19:19:02 -04:00
Keenan Brock 4dda866e0b
remove duplication for before_depth scope and friends 2024-10-22 18:50:24 -04:00
Keenan Brock fe8c2ce207
Extract format module extract 2024-10-22 18:50:23 -04:00
Keenan Brock 7176d19715
extract ancestry_depth_sql to module methods
want to call these from tests
2024-10-22 18:20:01 -04:00
Keenan Brock 8a4e7c0499
Merge pull request #687 from kbrock/ruby3.3
Test ruby 7.2
2024-10-22 17:26:57 -04:00
Keenan Brock 5129762631
Test ruby 7.2 2024-10-22 16:55:40 -04:00
Keenan Brock 404c5141e7
Merge pull request #686 from digitalfrost/patch-1
Make Supported Rails versions section of README easier to read
2024-10-22 16:36:32 -04:00
digitalfrost 07336a7a65
Make supported Rails versions easier to read
Add double space at end of line to force line break.
So that the independent clause about Rails 5.2 with 'update_strategy... is shown on a new line...
2024-10-18 17:46:19 +02:00
Keenan Brock d613f00c2c
Merge pull request #683 from fkmy/fix-typo-in-readme
Fix typo in README
2024-08-21 13:14:16 -04:00
fkmy 80abe3c566 Fix typo in README 2024-08-01 10:57:05 +09:00
Keenan Brock e1c0f355a5
Merge pull request #680 from instrumentl/in_subtree_of
add in_subtree_of? helper function, for symmetry
2024-06-14 16:01:50 -04:00
James Brown b594e99ac0 add in_subtree_of? helper function, for symmetry 2024-06-12 16:40:47 -07:00
Keenan Brock 498638e6f1
Merge pull request #681 from kbrock/sqlite_tests
Fix sqlite3 dependency
2024-06-12 19:26:01 -04:00
Keenan Brock 8cb59e6aed
Fix local sqlite3 build
Main change was to sqlite3. The 2.0 driver broke things

Locally, was having trouble compiling mysql2,
so hardc oded a version that is working
2024-06-12 19:18:03 -04:00
Keenan Brock d2e2f2a7fa
Merge pull request #678 from ytjmt/fix-readme
docs: fix README
2024-02-29 11:26:14 -05:00
ytjmt 2e7a5304e0
docs: fix README
- fix suggested collation for MySQL field
- s/runnnig/running
- s/Mysql/MySQL/
2024-02-29 00:15:36 +09:00
Keenan Brock c2df44cc8b
Merge pull request #677 from kbrock/rails_71
add tests for 7.1 and bump versions
2024-01-03 09:55:44 -05:00
Keenan Brock 9e6f653ba6
add tests for 7.1 and bump versions 2023-12-12 12:00:58 -05:00
Keenan Brock 9dab81f508
Merge pull request #674 from stefankroes/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 3 to 4
2023-09-26 10:59:15 -04:00
Keenan Brock 1613ac2463
Merge pull request #675 from ken3ypa/fix-typo-in-readme
Fix typo in README
2023-09-26 10:58:54 -04:00
Kensuke Yanase 99620d8675 fix typo in readme 2023-09-08 21:30:40 +09:00
dependabot[bot] a61da6c4e0
Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-04 13:19:35 +00:00
Keenan Brock af0d66dd25
Merge pull request #672 from ericgpks/fix/change_image_untransparent
change images on README to opaque
2023-08-28 17:28:17 -04:00
Eriko Sugiyama 25dd26e8a8 fix: change images to untransparents 2023-08-27 22:04:47 +09:00
Keenan Brock c7f49dddfd
Merge pull request #671 from kbrock/orphan_strategy_leafs_first_test
test orphan_strategy: :destroy deletes leafs first
2023-07-18 07:35:59 -04:00
Keenan Brock f1b8d95834
test orphan_strategy: :destroy deletes leafs first
validates https://github.com/stefankroes/ancestry/pull/635
2023-07-17 18:14:03 -04:00
Keenan Brock 5c637482f3
Merge pull request #669 from kbrock/fix_unique_warning_test
fix uniqueness warning in mysql 6.0 tests
2023-07-13 15:04:44 -04:00
Keenan Brock 08539c5ac5
test only: fix uniqueness warning
This test on mysql rails 6.0 was throwing the following warning:

DEPRECATION WARNING: Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1
2023-07-13 10:31:35 -04:00
Keenan Brock c08b07dcb0
Merge pull request #658 from kbrock/extensible_orphan_strategy
Extensible orphan_strategy
2023-07-13 09:48:17 -04:00
Keenan Brock 753013a1bb
Introduce orphan_strategy none
Many people override apply_orphan_strategy and either leave it blank or call custom methods from there.

Introducing `orphan_strategy: :none` to allows developers to skip default orphan handling.
From here a developer can add `before_destroy` with what ever logic is desired.

It is possible to introduce a custom orphan strategy with a mixin/concern,
but it doesn't seem to save any code and is not the best interface with too many nuances.
Leaving in the code for now but not promoting to a supported feature yet.
2023-07-13 09:40:10 -04:00
Keenan Brock cb9635cc3a
docs: Move (default) values to the end of the line
also tried for some consistency in messaging around attributes
2023-07-13 09:33:57 -04:00
Keenan Brock 22c199152d
Merge pull request #668 from kbrock/rebuild-counter-cache
Fix Rebuild counter cache
2023-07-13 09:15:18 -04:00
Keenan Brock 3e25711617
changelog entries 2023-07-13 08:57:40 -04:00
Ya-Rong, Teng d196dd4b80
fix: cast to char in child_ancestry_sql / rebuild_counter_cache
Before
======

We were casting to a fixed length char field. This can truncate parts of the path

After
=====

Using the conversion built into concat, so no need to CAST.

MySQL works best with CHAR
Postgresql works better with VARCHAR
2023-07-13 00:02:00 -04:00
Keenan Brock 49484a383a
In tests, change child_count cache column to default to 0
Before
======

New records had the children counter cache value of nil.

On mysql, update_counter_cache sets nodes with no children to nil.

After
=====

New records have the children counter cache set to 0

On mysql, update_counter_cache now sets to 0 (was nil)
new records set the nodes with no children to have a count of 0.

Docs
====

Looks like there is no documentation for the child counter cache.
Need to add to the README\
2023-07-12 23:57:54 -04:00
Keenan Brock 8bd197f04a
Merge pull request #667 from onerinas/patch-1
README update: Change depth_cache: true to cache_depth: true
2023-07-12 13:39:44 -04:00
Rinas 3fa8d97a12
Change depth_cache: true to cache_depth: true 2023-07-12 10:11:36 +04:00
Keenan Brock 0e17fe5efd
Merge pull request #664 from motokikando/patch-1
Fix typo in README.md
2023-05-15 16:14:37 -04:00
motokikando ac5ef56dd2
fix typo 2023-05-10 22:22:13 +09:00
Keenan Brock 6d0318e30e
Create CODE_OF_CONDUCT.md
standard v2.0 code of conduct
2023-04-14 15:48:11 -04:00
Keenan Brock ef5be9d33d
Merge pull request #657 from kbrock/touch_sql
Fix update_strategy=sql for touch sql and update hooks
2023-04-10 21:55:35 -04:00
Keenan Brock a84b0a3403
Example for hook with update_strategy = :sql
If we are using update_strategy = :sql
  the before_save will not get called, but the update_descendants_hook will get called
If we are using update_strategy = :ruby
  the before_save will get called, (and update_descendants_hook will be ignored

So this example works for both.
2023-04-10 21:47:28 -04:00
Keenan Brock c0db14d3a7
Introduce update_strategy=sql hooks
This allows additional functionality to implement sql only hooks

depth_cache_column was extracted with thoughts that in the future, it can be be added
only if depth_cache_column is added.
2023-04-10 21:30:57 -04:00
Keenan Brock dbd23f2791
fix touch with sql update strategy
note:
ActiveRecord 6.0 introduced `update_all(Hash)`
Fixing depth_cache_column support in a string is simple.
It is the escaping of dates that has me moving to the hash syntax.

Since Rails 5.2 support dropped 10 months ago,
Deciding to drop 5.2 update_strategy=sql support
2023-04-10 21:27:25 -04:00
Keenan Brock d7b577a2c6
Merge pull request #663 from kbrock/child_ancestry_sql
rebuild_counter_cache
2023-04-10 21:12:17 -04:00
Keenan Brock 31bcc1357f
remove rebuild_depth_cache_sql debug comments 2023-04-10 21:07:31 -04:00
Keenan Brock 1f441c2e66
child_ancestry_sql
rebuilding depth cache and counter cache in sql

mysql was not able to update with the sub select.
So added a join table instead.

mysql was not able to use a VARCHAR column, so used CHAR instead
2023-04-10 21:07:28 -04:00
Keenan Brock 8f06902839
Merge pull request #659 from kbrock/alt_ancestry_column_suite
Alt ancestry column suite
2023-03-28 22:11:14 -04:00
Keenan Brock c4832d885a
moved duplicate testing code over to test_helpers 2023-03-28 22:06:11 -04:00
Keenan Brock fd0b86d215
version file bump 2023-03-28 21:45:10 -04:00
Keenan Brock 92d9c6833b
remove alt specific tests that are covered by other tests
we are running all tests with an alt ancestry, so these are no longer necessary
2023-03-28 21:42:51 -04:00
Keenan Brock 1d310894cd
configure ancestry_column name from matrix
Things changed a bit here.
In tests, we are now always passing ancestry_column into has_ancestry (in with_model)
This value is from an env variable or defaulting to ancestry

So when code calls has_ancestry, we're not sure the name of the field in the database
so we need to be more careful.

Mostly noticeable in the tests that manually call has_ancestry
And the test of the default ancestry column name (which assumed has_ancestry was being called without values)
2023-03-28 21:42:50 -04:00
Keenan Brock eb3f6217d0
don't reference column ancestry in tests (use model.ancestry_column) 2023-03-28 21:42:50 -04:00
Keenan Brock e9a6b85ed4
Remove materialized path specific tests 2023-03-28 21:42:49 -04:00
Keenan Brock 257728b342
Merge pull request #656 from mitsuru/fix-sort_by_ancestry_with_ancestry_column
Fixes `.sort_by_ancestry` with custom `ancestry_column`
2023-03-28 21:03:43 -04:00
Mitsuru Hayasaka 009aafc4c5 Fixes `.sort_by_ancestry` with custom `ancestry_column` 2023-03-29 05:06:38 +09:00
Keenan Brock be23abb704
Merge pull request #654 from kbrock/cache_column_option
Cache column option
2023-03-26 17:49:05 -04:00
Keenan Brock 963c1eaba3
add deprecation warnings to changelog 2023-03-26 00:41:10 -04:00
Keenan Brock 54dcb36540
drop ancestry_format class accessor
In our code, if the functionality depends upon the ancestry format,
we need to move that into the format modules
2023-03-26 00:03:43 -04:00
Keenan Brock fe20579f56
update depth_cache_column using math
Use math to adjust the depth_cache instead of re-calculating the depth

I had wanted to use ancestry_depth_sql, but that was using the ancestry value
and putting in the new ancestry value would have been too complicated

goal: remove the regular expression
2023-03-26 00:01:09 -04:00
Keenan Brock 0000a3cbcc
Changelog comments 2023-03-25 23:57:50 -04:00
Keenan Brock ed20bfa6c3
Implement depth as a subquery
The main reason I introduced depth sql is to improve rebuild_depth_cache_sql!
Throwing this into the scopes is a bonus. (but not really)

This works great for the update, but the extra scopes will not perform well.
If you need to use them, consider adding an index on that equation
2023-03-25 23:35:29 -04:00
Keenan Brock de55f99c3a
Don't define scopes on models that do not implement depth caching
a. don't define dynamic methods that will just raise an exception.
b. simpler to just define the scopes by hand than with a loop
2023-03-25 23:35:29 -04:00
Keenan Brock 304fb8f3ed
Merge :depth_cache_column and :cache_depth options
The boolean flag was redundant for the column
Have kept the previous option available, but included a deprecation message


has_ancestry :cache_depth => "ancestry_depth2"
has_ancestry :cache_depth => true # "ancestry_depth2"

deprecated:

has_ancestry :cache_depth => true, :depth_cache_column => "ancestry_depth2"
2023-03-25 23:35:28 -04:00
Keenan Brock 10866f19a8
Merge pull request #653 from kbrock/changelog
Added current changelog entries for master/5.0 [skip_ci]
2023-03-25 22:38:44 -04:00
Keenan Brock 6da57931be
Updates to changelog
added head changes (a few TODOs have slipped in there)

added change log for 4.3.2
2023-03-25 22:32:51 -04:00
Keenan Brock 3dcb13d568
Merge pull request #652 from kbrock/rename_before_last_save
Rename internal methods names to be more rails consistent
2023-03-25 21:42:51 -04:00
Keenan Brock a3f39eab3d
Rename internal methods names to be more rails consistent
Don't think these methods are used by extension developers.

- `unscoped_descendants_before_save`  => `unscoped_descendants_before_last_save`
- `descendant_before_save_conditions` => `descendant_before_last_save_conditions`
- `child_ancestry_before_save`        => `child_ancestry_before_last_save`

Did not rename `child_ancestry` => `child_ancestry_in_database`.
I went back and forth on this one.
I put in a note to hopefully help anyone confused by the code using `_in_database`.

Now throwing an error from `child_ancestry_before_last_save` for newly saved records. (rails >= 6.1)
This error is thrown if this scope is used from an `after_save` hook on a new record.
Either change the hook to an `after_update` or guard with a `previously_new_record?`.
For new records, this value may point to `root` and bad things could happen.
2023-03-25 21:37:29 -04:00
Keenan Brock 903b9dde38 Merge pull request #651 from kbrock/touch_tests
Touch tests
2023-03-25 20:30:20 -04:00
Keenan Brock 3e1d33444d
fix hook tests
reset tests after records are created
the hooks were called in create and in running the behavior

materialized path2 is a little strange in how it considers ancestry_changed?,
so the values were different per format.
These now behave the same for both formats
2023-03-25 20:27:06 -04:00
Keenan Brock 907f598b87
fix touch tests
some tests were not reloading the records to pickup changes
some tests were starting with a recent updated_at. so they always showed as changed

in tests, fixed strange bug in mysql and FORMAT=materialized_path only:
if an update_at is recent, mysql will not send the value to the database to change the updated_at
In our test case, the local updated_at is now, and the database has an updated_at as a while ago
so it is not sending the new update_at to the db.

It looks like rails 7.1.0.a has this fixed (not sure when)
But changed update statements to include a reload so rails knows to send this column across.

This feels like a test only issue so I'm ok modifying only the tests.
2023-03-25 20:27:06 -04:00
Keenan Brock f7810d5424
Merge pull request #649 from kbrock/drop_touch
Drop touch and ancestry_primary_key_format
2023-03-25 19:07:11 -04:00
Keenan Brock d4be960589
drop global ancestry_primary_key_format 2023-03-25 00:42:05 -04:00
Keenan Brock 7a8b04ee5c
drop use of ancestry_options[:touch] 2023-03-25 00:42:05 -04:00
Keenan Brock dbb09c2453
Merge pull request #648 from kbrock/splitup_validations
Splitup validations
2023-03-25 00:41:47 -04:00
Keenan Brock 5a4e0dd64a
Add tests to verify contents of the columns
we were validating that bad stuff didn't get in there.
These tests put values into there and verify the right stuff comes out
2023-03-25 00:34:47 -04:00
Keenan Brock 3401e0a32d
Revert "add back ancestor_ids{_in_database,_before_last_save} and parent_id"
This reverts commit 015ef1eeca.

it was already here. Both git and I missed the duplicate
2023-03-25 00:22:49 -04:00
Keenan Brock 659952451c
split materialized path from materialized path 2 tests
they are very copy/paste.
These tests should only be verifying the string format
I'm guessing other strategies will be a copy paste as well.
2023-03-25 00:21:13 -04:00
Keenan Brock 6c2b052908
split validations into materialized_path 1 and 2 pt 1
doing this to keep the history with the tests
2023-03-25 00:12:23 -04:00
Keenan Brock 2b211d88c9
test has_parent and root? for single node attribute 2023-03-25 00:08:49 -04:00
Keenan Brock 3cc9c2ccc7
Merge pull request #647 from kbrock/add_apth_ids_master
Add path_ids_in_database back
2023-03-24 23:24:25 -04:00
Keenan Brock 5b839d9fe1
add path_ids_in_database back
This was removed by #589
Adding it back.
2023-03-24 23:14:53 -04:00
Keenan Brock dbbd720856
test before_last_save methods
The destroy and the after save callbacks leverage the before_last_save methods.
I had trouble testing those values outside callbacks, and in the callback, I
didn't know the best way to assert values.
2023-03-24 23:14:52 -04:00
Keenan Brock c9fdd41c18
test in_database and children throughout the lifecycle
assert_attributes really changed
there are so many attributes to test for each node. It made the tests unreadable.
This required changing the format of the parameters

The matrix felt like a hack at first, but it is not programmatically testing stuff.
there is a hardcoded list of what needs to be done for each node. Which in essence
is what tests do.

This is now properly testing all these attributes for in_database along the whole lifecycle
of these objects
2023-03-24 23:14:52 -04:00
Keenan Brock 247e745e2e
change nav tests 2
Change nav test to more clearly show what is being tested
2023-03-24 23:14:52 -04:00
Keenan Brock 015ef1eeca
add back ancestor_ids{_in_database,_before_last_save} and parent_id
this was removed by 0fcd12fd https://github.com/stefankroes/ancestry/pull/589

adding fields back.
added tests to exercise
2023-03-24 23:14:52 -04:00
Keenan Brock 9b3a034661
Merge pull request #635 from kbrock/ordered_descendants
Delete nodes from leafs up to root
2023-03-19 22:21:06 -04:00
Keenan Brock d00654a4e8
Delete nodes from leafs up to root
When destroying dependents, start with the furthest down leafs and work
your way up to the current node

https://github.com/stefankroes/ancestry/issues/90
2023-03-19 22:15:35 -04:00
Keenan Brock 6f3326e960
fixed changelog v4.3 2023-03-19 22:13:03 -04:00
Keenan Brock 0bba8f6fb7
Merge pull request #643 from kbrock/add_ancestor_ids_in_database
Add back ancestor_ids_in_database
2023-03-17 21:30:34 -04:00
Keenan Brock df7aeb31c4
431 changelog 2023-03-17 21:13:08 -04:00
Keenan Brock e76cba3a2f
add back ancestor_ids{_in_database,_before_last_save} and parent_id
this was removed by 0fcd12fd https://github.com/stefankroes/ancestry/pull/589

adding fields back.
added tests to exercise
2023-03-17 21:13:08 -04:00
Keenan Brock b559d8b583
Merge pull request #642 from kbrock/random_tests
ancestry column tests
2023-03-17 21:00:08 -04:00
Keenan Brock 8f7ef4fecd
display default_update_strategy in test logs 2023-03-17 20:54:04 -04:00
Keenan Brock 43ceac4c17
fill out tests to cover more fields
I never could fully read the tree navigation test.
rewrite all methods in same order (looks like copy/paste)
from here, I feel much more comfortable adding and verifying tests exist
2023-03-17 20:54:04 -04:00
Keenan Brock 290f8a120b
Moved update hook tests into hook_tests 2023-03-17 20:54:03 -04:00
Keenan Brock 19f8434334
Merge pull request #634 from kbrock/update_descendants_changed
Changes to update_descendants_with_new_ancestry
2023-03-15 21:08:39 -04:00
Keenan Brock 11fa95aa2a
Changes to update_descendants_with_new_ancestry
- this is called after a save (/via #589 ), dropping `new_record?`
- putting `ancestry_changed?` in the callback definition. not needed in the method
- had wanted to remove `sane_ancestor_ids?` since validations already ran
  Unfortunately, `update_attribute` (read: no validations) allows bogus ancestry through.

Decided to continue skipping updates if the ancestry is deemed not valid.
It can only get this way if a user skips validations.

Not sure the desired behavior if something is corrupting ancestor's ancestry value.
I can see reason why we may want to update them all.
For now, keeping this skip updates check in place
2023-03-14 19:19:50 -04:00
Keenan Brock e246bee2d0
test sane_ancestor_ids? method 2023-03-14 16:18:06 -04:00
Keenan Brock 07c74caab9
Merge pull request #629 from kbrock/more_sti_tests
reverting deprecating has_ancestry in subclasses
2023-03-14 16:04:47 -04:00
Keenan Brock 91637e0b12
un-deprecate ancestry_base_class
keeping ancestry_base_class, so defining has_ancestry on a non-base class
will work fine
2023-03-14 16:01:50 -04:00
Keenan Brock 741199aee8
Ensure has_ancestry in subclasses do not leak 2023-03-14 16:01:50 -04:00
Keenan Brock 257af8a0bc
Merge pull request #632 from kbrock/apply_orphan_strategy
Support overriding apply_orphan_strategy
2023-03-14 16:00:57 -04:00
Keenan Brock a39494657e
Merge pull request #633 from kbrock/ancestry_writers
update Ancestry writers
2023-03-14 15:50:28 -04:00
Kirill Shnurov 0c0240aefe
remove ancestry_base_class instance reader 2023-03-14 15:44:18 -04:00
Kirill Shnurov 918e443d94
remove ancestry_* writers 2023-03-14 15:16:27 -04:00
Keenan Brock f04349b665
Support apply_orphan_strategy
apply_orphan_strategy was removed in 1a25355
Many gems override this method to apply a custom orphan strategy

This adds support back and adds a test around the behavior

If a developer uses super in this method, that will break
You will need to directly call the desired apply_orphan_strategy_* instead
2023-03-14 14:38:51 -04:00
Keenan Brock 7621f09363
Merge pull request #624 from kbrock/github_matrix_tests
Tests more closely follow suggested configurations
2023-03-11 13:17:52 -05:00
Keenan Brock beeeb4e275
better ancestry column testing
- properly configure null columns with materialized_path2
- support and test binary columns (for mysql)
- make update_strategy=:sql for postgres explicit rather than auto selected
- display options to console (probably temporary)
2023-03-11 13:14:16 -05:00
Keenan Brock a5b2d27aa5
remove ancestry_format from tests
This is not really a value we should be exposing

The test globally set the format, so they should know what format is being used
without asking
2023-03-11 11:05:14 -05:00
Keenan Brock 0ba9506372
Merge pull request #626 from kbrock/ancestry_sti_test
Deprecate adding ancestry to the middle of an STI tree
2023-03-10 21:13:13 -05:00
Keenan Brock bf947ebb09
Deprecate adding ancestry to the middle of an STI tree
Provide deprecation warning for has_ancestry in the middle of a tree

Still haven't thought up with a good use case for this.
2023-03-10 15:33:19 -05:00
Keenan Brock 3879d3d522
Update ancestry sti subclass test
the helper function `with_model` calls has_ancestry for us
So essentially we are calling this twice.

- fixed to not define has_ancestry at the root node.

This is now ensuring that search and the counter_caches are working.
2023-03-10 15:22:38 -05:00
Keenan Brock cfa09bdc61
Merge pull request #623 from kbrock/fix_43_changelog
fix changelog line [skip ci]
2023-03-10 10:46:50 -05:00
Keenan Brock 6e25859603
fix changelog line [skip ci] 2023-03-10 10:44:49 -05:00
Keenan Brock 8b71773e8b
Merge pull request #619 from kbrock/counter_cache_column
Counter cache column
2023-03-09 17:39:39 -05:00
Keenan Brock ff8ee39f7a
drop _counter_cache_column
The value is available, no reason to define a duplicate method
2023-03-09 17:36:10 -05:00
Keenan Brock 187dc1d948
Merge pull request #617 from kbrock/orphan_strategy
Drop Orphan strategy class attribute
2023-03-09 17:07:57 -05:00
Keenan Brock 1a2535567c
drop orphan_strategy accessor
Instead of a case statement in the callback, pull each strategy into own method
2023-03-09 16:22:17 -05:00
Keenan Brock 796ef7c320
drop orphan_strategy=
Attributes that affect the way ancestry works need to be set in has_ancestry
and not changed later on
2023-03-09 16:22:17 -05:00
Keenan Brock ea0bc1e3f4
Merge pull request #621 from kbrock/release_v430b
Release v430b
2023-03-09 16:19:28 -05:00
Keenan Brock 04fcb08baa
update arrange in docs 2023-03-09 16:15:07 -05:00
Keenan Brock 66a20f2a3b
Update Readme documentation 2023-03-09 16:15:07 -05:00
Keenan Brock 92b68e63be
updates to changelog 2023-03-09 16:15:05 -05:00
Keenan Brock aeab49dc4a
Merge pull request #415 from kbrock/partial_tree_sort
Make arrange_nodes more memory efficient
2023-03-04 22:43:23 -05:00
Keenan Brock b5f22921fd
Merge pull request #614 from kbrock/release_v430
release v4.3 CHANGELOG [skip ci]
2023-03-04 18:30:14 -05:00
Keenan Brock a078aa2daf
release v4.3 CHANGELOG
* Fix: materialized_path2 strategy https://github.com/stefankroes/ancestry/pull/597
* Fix: descendants ancestry is now updated in after_update callbacks https://github.com/stefankroes/ancestry/pull/589
* Document updated grammar https://github.com/stefankroes/ancestry/pull/594
* Documented `update_strategy` https://github.com/stefankroes/ancestry/pull/588
* Fix: fixed has_parent? when non-default primary id https://github.com/stefankroes/ancestry/pull/585
* Documented column collation and testing https://github.com/stefankroes/ancestry/pull/601 https://github.com/stefankroes/ancestry/pull/607
* Added initializer with default_ancestry_format https://github.com/stefankroes/ancestry/pull/612
* ruby 3.2 support https://github.com/stefankroes/ancestry/pull/596
2023-03-04 18:27:10 -05:00
Keenan Brock f6213602da
Merge pull request #616 from kbrock/default_ancestry_format_fix
Default ancestry format to materialized_path
2023-03-04 00:57:28 -05:00
Keenan Brock 719a457f2c
flatten_arranged_nodes: Don't allocate a arrays per child
Since this is in its own method, we no longer have such a big call stack
Also, we are no longer creating a 2 new arrays for every set of children
2023-03-04 00:00:50 -05:00
Keenan Brock 8414d90832
extract flatten_arranged_nodes
this is the part responsible for taking a tree and producing an array of nodes
with the parent node before the children

Since children is always a hash, this will never enter the sorting code
and never require the block. so the sorting and the &block was dropped.

This works because ruby enumerates the hashes with insert order (no need to sort again)
2023-03-03 23:38:08 -05:00
Keenan Brock 9ed56654aa
Condense sort_by_ancestry sorting algorithm
- Only preforms one compare (<=>) vs two ( == and <=> )
- null ancestry is a space (ensures it sorts first when using materialized path 2)

But it does not actually change the sorting algorithm
It is still sorting levels by id (not optimal)
And still issues when missing nodes
2023-03-03 23:38:08 -05:00
Keenan Brock 22a3f18797
update sort tests
We are sorting records that come back from a query.
That means certain assumptions can be made about the records coming back
It will not contain cousins without at least one parent node in there.
2023-03-03 23:38:07 -05:00
Keenan Brock 6a29b385f9
Default ancestry format to materialized_path
Wish we did not have to do this,
but for now, no breaking changes

Fixes bug introduced in 53f1914
2023-03-03 20:46:31 -05:00
Keenan Brock cb7f3032b5
Merge pull request #615 from Fryguy/fix_readme_rendering
Fix README rendering [skip ci]
2023-03-03 11:24:49 -05:00
Jason Frey 5c047e2398
Fix README rendering [skip ci] 2023-03-03 11:18:27 -05:00
Keenan Brock ede43a2ad5
Merge pull request #607 from kbrock/more_collation_documentation
Improve documentation around ancestry field and collation
2023-03-02 23:27:22 -05:00
Keenan Brock 18ad23e1aa
Improve documentation around ancestry field and collation
postgres: binary data type is not currently working
mysql: binary data type or string with binary collation is working

Had wanted to go with binary for all, but test would not pass
2023-03-02 23:24:31 -05:00
Keenan Brock d19d0eb741
cache ancestry collation
Since this is used in all tests, best to cache this
2023-03-02 21:52:09 -05:00
Keenan Brock 7ec1a51c98
Merge pull request #613 from kbrock/partial_tree_sort_tests
Partial tree sort tests
2023-03-02 21:50:23 -05:00
Keenan Brock e222b5d788
Fix sorting tests.
Drop STRICT. We already follow these cases.
I also cant remember why that case is significant / what it is trying to tell

Drop a number of if cases that I'm pretty sure are deterministic
I also documented why the non-optimal values are returned.

use a common ranking function: RANK_SORT
2023-03-02 21:45:11 -05:00
Keenan Brock 53f191488c
Merge pull request #612 from kbrock/default_ancestry_format
Add default ancestry format and primary key format
2023-03-02 21:41:55 -05:00
Keenan Brock 0fe59214a8
Merge pull request #611 from kbrock/v61_default
default gemfile is 6.1
2023-03-02 16:57:03 -05:00
Keenan Brock 559fce9ba9
Add default ancestry format and primary key format
These tend to be the same across a whole database.

The primary key format is used for databases with uuids for the primary key
The ancestry format makes it easy for a new user to get up and running
using a different ancestry encoding format
2023-03-02 13:51:58 -05:00
Keenan Brock 51d856fa51
default gemfile is 6.1 2023-03-01 20:40:54 -05:00
Keenan Brock bdb9143afd
Merge pull request #601 from kbrock/latin
Document and test with column collation
2023-02-19 20:40:19 -05:00
Keenan Brock ced8a6fe9c
set ancestry column collation for tests
Currently, we are using the default database collation for the ancestry column.
In most rails apps, this is some form of unicode locale.

This is skipping indexes for like.
On ubuntu systems, it is ignoring slashes for sorting, which causes all sorts of problems

This changes tests to just use a simple binary comparison - ascii byte for byte.
This gets better results, uses indexes for LIKE, and is faster for all comparisons.

ENV["ANCESTRY_COLLATION"] should never be needed unless you are testing performance characteristics
2023-02-16 12:36:18 -05:00
Keenan Brock e7459e920c
add function for postgres detection 2023-02-16 12:34:39 -05:00
Keenan Brock f66c906ed8
Merge pull request #597 from kshnurov/materialized_path2_fix
Fix materialized_path2
2023-02-14 22:57:54 -05:00
Kirill Shnurov 73468ba1da fix for Ruby < 2.7 2023-01-05 19:11:31 +07:00
Kirill Shnurov ac788217cf fix materialized_path2 2023-01-05 18:37:31 +07:00
Keenan Brock 8a46ea1aa0
Merge pull request #589 from kshnurov/update_descendants_after_update
update_descendants_with_new_ancestry in after_update
2023-01-03 19:56:12 -05:00
Keenan Brock c3b0540a54
Merge pull request #596 from petergoldstein/feature/add_ruby_3_2_to_ci
Adds Ruby 3.2 to the CI matrix
2023-01-03 16:31:41 -05:00
Peter Goldstein f3460357ee Adds Ruby 3.2 to the CI matrix 2022-12-28 22:40:56 -05:00
Keenan Brock a70919d11b
Merge pull request #594 from omarr-gamal/master
fixed minor grammatical error in README.md where a verb following "will" didn't come in infinitive form
2022-12-09 13:09:31 -05:00
Omar Gamal AbdelMageed 093855986f
fixed minor grammatical error where a verb following "will" didn't come in infinitive form 2022-12-07 23:34:11 +02:00
Kirill Shnurov 90fb1a1ba2 skip callbacks test for sql strategy 2022-09-14 20:38:47 +06:00
Kirill Shnurov 00647bcdda test update_descendants_with_changed_parent_value 2022-09-13 13:16:40 +03:00
Kirill Shnurov 0fcd12fd36 update_descendants_with_new_ancestry in after_update 2022-09-13 13:16:40 +03:00
Keenan Brock aabf5d2112
Merge pull request #585 from Zhong-z/master
Fix non-default primary_key in find_by and where queries
2022-09-12 10:20:51 -04:00
Keenan Brock 0b6a736b58
Merge pull request #588 from victorfgs/add-sql-strat-docs
add `update_strategy` documentation
2022-09-06 09:46:40 -04:00
Victor Felix 68c329e7df add config doc 2022-09-01 18:45:34 -03:00
Keenan Brock fc83408080
Merge pull request #583 from stefankroes/dependabot/github_actions/actions/checkout-3
Bump actions/checkout from 2 to 3
2022-08-11 19:41:04 -04:00
Zhong Zheng d161895fdc Fix non-default primary_key in find_by and where queries 2022-07-10 22:44:37 +10:00
dependabot[bot] fabe8c3436
Bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-21 21:23:24 +00:00
Keenan Brock a81c30cee9
Merge pull request #582 from petergoldstein/feature/add_dependabot_for_github_actions
Add Dependabot for GitHub Actions
2022-06-21 17:22:58 -04:00
Peter Goldstein 103eb47230 Add Dependabot for GitHub Actions 2022-06-21 13:18:50 -07:00
Keenan Brock 1441f9d73d
Merge pull request #580 from kbrock/v42
Version 4.2.0
2022-06-09 23:35:54 -04:00
Keenan Brock 7534861936 Version 4.2.0
* added strategy: materialized_path2 #571
* Added tree_view method #561 (thx @bizcho)
* Fixed bug when errors would not undo callbacks #566 (thx @daniloisr)
* ruby 3.0 support
* rails 7.0 support (thx @chenillen, @petergoldstein)
* Documentation fixes (thx @benkoshy, @mijoharas)
2022-06-09 20:54:33 -04:00
Keenan Brock 1db4278f6b
Merge pull request #579 from kbrock/ruby31-warning
fix test configuration warning
2022-06-09 20:45:06 -04:00
Keenan Brock 88c4676348 fix test configuration warning
test/environment.rb:49: warning: `**' interpreted as argument prefix
2022-06-09 20:34:12 -04:00
Keenan Brock ae24dade82
Merge pull request #577 from petergoldstein/feature/updated_rails_7
Rebased #564 with Ruby 3.1 support and updated CI
2022-06-09 14:52:47 -04:00
Peter Goldstein 9a4a444705 Support aliases in file load 2022-06-09 10:59:28 -07:00
Peter Goldstein 741d0456da Add Rails 7 / Ruby 3.1 to the CI matrix 2022-06-09 10:42:11 -07:00
Allen C b4bac83a63 add rails 7 support 2022-06-09 10:40:53 -07:00
Keenan Brock 6a6d3e7ec6
Merge pull request #576 from kbrock/warnings
Minor Warnings
2022-05-05 14:32:02 -04:00
Keenan Brock 1754f39c1e change hostname to host in configuration 2022-04-29 10:33:55 -04:00
Keenan Brock a7ccf0260e remove unused variable t 2022-04-29 10:33:43 -04:00
Keenan Brock 946f20d70f
Merge pull request #571 from kbrock/materialized_path2
Materialized path2
2022-04-29 10:01:27 -04:00
Keenan Brock c82291ca1c
Merge pull request #575 from kbrock/find_by_id
fix parent for non-standard primary key
2022-04-19 14:39:04 -04:00
Keenan Brock 5555e384d8 fix parent for non-standard primary key 2022-04-19 10:21:12 -04:00
Keenan Brock 41a6603487
Merge pull request #572 from kbrock/retire_hakiri
remove EOL service hakiri from readme
2022-01-25 18:03:56 -05:00
Keenan Brock e5788a8fef remove EOL service hakiri 2022-01-25 18:00:57 -05:00
Keenan Brock 40b8123ee3 Introduce materialized_path2 strategy
Before:
ancestry = root? ? nil : ancestor_ids.join("/")

After:

Implications:

ancestry == "#{parent.ancestry}#{parent.id}/" # always

query for my children/descendants no longer has an OR (big win)
no more IS NULL sorting shenanigans
2022-01-05 21:19:16 -05:00
Keenan Brock 208343612b add ROOT to materialized path
materialized path uses ancestry.nil? to be the root
This puts that logic into a variable

trying hard to remove the assumptions about the ancestry column:
- ancestry.nil? means something
- field is an empty string to mean no ancestors
2022-01-05 21:18:58 -05:00
Keenan Brock cf6fe14030 use active record or vs arel or 2022-01-05 21:17:36 -05:00
Keenan Brock 9c2a7e92ae
Merge pull request #565 from benkoshy/patch-1
Update README.md
2022-01-05 18:37:11 -05:00
Keenan Brock d33a11b78e
Merge pull request #569 from kbrock/kwargs
ruby 3.0 kwargs warning
2022-01-05 18:36:41 -05:00
Keenan Brock b55397ba4d
Merge pull request #561 from Bizcho/master
Add tree_view method
2022-01-05 18:35:47 -05:00
Keenan Brock 8d7dbdfa71
remove unneeded comments
git history can point people to any issues handled by this pr
2022-01-05 18:35:30 -05:00
Keenan Brock 13a2cec9bf
Merge pull request #570 from kbrock/actions
remove travisyml
2022-01-05 18:32:39 -05:00
Keenan Brock e99fb0760a ruby 3.0 kwargs warning 2022-01-05 18:31:38 -05:00
Keenan Brock e9737dcc87 remove travisyml
using github workflow actions, no need for travis.yml
see .github/workflows/run_test_suite.yml
2022-01-05 18:23:52 -05:00
Keenan Brock 72e1c224b6
Merge pull request #568 from kbrock/assert_nil
assert_nil
2022-01-05 17:27:13 -05:00
Keenan Brock 79abd3391f
Merge pull request #566 from daniloisr/patch-1
Use `ensure` to revert @disable_ancestry_callbacks
2022-01-05 17:27:03 -05:00
Keenan Brock b1f2f687f0
Merge pull request #567 from kbrock/peg_3.0
ensure ruby 3.0 not 3 (which means 3.1)
2022-01-05 17:23:51 -05:00
Keenan Brock 7afdc1eb1c assert_nil 2022-01-05 17:23:22 -05:00
Keenan Brock 38d8acbf56 ensure ruby 3.0 not 3 (which means 3.1) 2022-01-05 17:20:37 -05:00
Danilo Resende 68dcb0bd55 Use `ensure` to revert @disable_ancestry_callbacks 2021-12-28 19:09:09 -03:00
Ben Koshy 43e5c4c2fd
Update README.md
### What is this PR?

* Add detail re: customizing column names.

### Why is it required?

Because it may not be convenient to name the column: 'ancestry' - I imagine (perhaps incorrectly) that many would prefer to customize the column name to something better suited to their use-cases (e.g. https://github.com/stefankroes/ancestry/issues/273).

I hope this change is helpful - if it is not, feel free to close without compunction etc.
2021-12-28 11:33:20 +11:00
Luis Valdez 46957a5fa0 Add tree_view method 2021-11-22 21:45:33 -07:00
Keenan Brock be5e6d8ed5
Merge pull request #557 from mijoharas/feature-add-docs-on-indexes
Document index and limitation of ancestry column size.
2021-09-07 07:55:24 -04:00
Michael Hauser-Raspe 71c3c73566 Document index and limitation of ancestry column size. 2021-08-16 09:22:36 +01:00
Keenan Brock a85f44853d
Merge pull request #554 from kbrock/v4.0.1
comments for v4.1.0
2021-06-28 13:55:51 -04:00
Keenan Brock 1bbb0a9675 comments for v4.1.0 2021-06-24 23:41:14 -04:00
Keenan Brock 766eb3db0c
Merge pull request #550 from goodproblems/fix-STI-counter-cache
Fix issue with counter cache and STI
2021-06-23 17:35:04 -04:00
Matt Vague 86b84ceec0 Fix issue with counter cache and STI 2021-06-23 09:45:53 -07:00
Keenan Brock 99cbcd3fe2
Merge pull request #553 from kbrock/class_def_fix
fix tests and class definitions
2021-06-14 14:04:22 -04:00
Keenan Brock 317c3ba69e fix tests and class definitions
Active record works better when defining a class rather than using anonymous classes
The with_model helper takes care of all the details.

A few of the STI examples did not properly handle undefining these temporary classes.

This PR properly undefines the temporary test classes
2021-06-14 14:02:17 -04:00
Keenan Brock a90c99075d
Merge pull request #551 from kbrock/more_valid
only run validation on ancestry column
2021-06-14 13:41:08 -04:00
Keenan Brock ef78cbed5c only run validation on ancestry column
was running into issues with pre-validation callbacks.
they were running a bunch of queries and requiring columns to be
in the model when we were only focused on the ancestry columns

this fixes that

before:

- run all prevalidation call backs
- run all validations
- check that ancestry did not thow an exception

after:

- run only ancestry validations

also consolidates 2 different ancestry checks
2021-06-09 09:43:33 -04:00
Keenan Brock 716b2d2d25
Merge pull request #542 from d-m-u/fix_sanity
change the valid check to not only run on the entire object
2021-06-03 12:06:47 -04:00
Keenan Brock b70976f1f4
Merge pull request #541 from kbrock/nulls_first
oracle use NULLS first
2021-05-26 04:10:04 -04:00
Keenan Brock 17c73ba9cc
Merge pull request #546 from kbrock/bad_root
return self if root is invalid
2021-05-26 04:09:34 -04:00
Keenan Brock 57a09f76e6 return self if root is invalid
much like we do with parent, if the root_id is invalid, then we continue on
as if nothing has happened wrong.

It was hard to decide what to return but returning self seems like the best option.
2021-05-26 04:03:20 -04:00
Keenan Brock c26c2ffbfa oracle use NULLS first
the order of nulls relative to columns with values is setup as
a default value in databases, which can be changed. The default for
postgres is to have nulls last. Looks like oracle has the same.

This PR has oracle enhanced driver follow suite as postgres.

Postgres and rails 6.0 and earlier had issues with the null first, so this is
feature limited to 6.1 and later
2021-05-26 04:02:21 -04:00
Keenan Brock 4d413b22d9
Merge pull request #547 from kbrock/upgrade_rails
Upgrade rails versions
2021-05-26 03:40:06 -04:00
Keenan Brock 57bb1aa4f9 Upgrade rails versions
upgrading to the latest versions of rails
2021-05-26 03:36:17 -04:00
Keenan Brock 1d02551337
Merge pull request #545 from kbrock/remove_overalls
remove coveralls
2021-05-26 03:33:55 -04:00
Keenan Brock 447f17f455 run coverage with ENV["COVERAGE"] 2021-05-26 03:30:00 -04:00
Keenan Brock 460fdff9ba drop mysql(1) driver support
since rails around 5.1, mysql2 has been the only supported driver.
We dropped support for the old mysql driver, but left the travis configuration in there by mistake

this is just cleaning that out

instead mysql and mysql2 use mysql2 driver
2021-05-26 03:30:00 -04:00
Keenan Brock b3eb72f7bb remove travis status 2021-05-26 03:30:00 -04:00
Keenan Brock 97cdc5df13 remove coveralls
getting some errors with coveralls

not really paying attention to these metrics. removing this to simplify the gem dependencies
2021-05-26 03:30:00 -04:00
Keenan Brock a1a2398f07
Merge pull request #548 from kbrock/change_database_connectivity
point to container databases
2021-05-26 03:28:51 -04:00
Keenan Brock 5161639685 Use database from services
We were running containers with databases but also databases locally

- only run the container databases
- ensure database.ci.yml has the same credentials as setup in the workflow
- let the containers create the databases (may roll this back when local is manually set)
- run sqllite and postgres first to give mysql service time to start
- add pauses for postgres and mysql (though only mysql seems to sometimes need it)
- better error message when DB is not found in database.yml

references:

- https://github.com/docker-library/docs/blob/master/postgres
- https://github.com/docker-library/docs/blob/master/mysql
- https://dev.mysql.com/doc/refman/8.0/en/environment-variables.html
2021-05-26 03:23:06 -04:00
Keenan Brock e8f67eab41
Merge pull request #543 from kbrock/collate
introduce COLLATE_SYMBOLS=false to talor test environments
2021-05-24 11:05:25 -04:00
Keenan Brock cd5dc589a6
Merge pull request #544 from kbrock/latest_mysql
upgrade mysql
2021-05-24 11:00:29 -04:00
Keenan Brock 67bb06ef84 introduce COLLATE_SYMBOLS=false to talor test environments
Most database ship with locales built in so they are consistent across installations.
Postgres does not ship with locales so it uses the ones in the operating system.

In the locales there is collation, which basically defines how to sort. We will mostly
notice this in the following 2 ways:

- case sensitive sorting
- include symbols in the sorting

case sensitive: "B", "E", "c", "d"
non-case Insensitive: "B", "c", "d", "E"
include symbols: "Sams Golf Shop", "Sam's Cantina"
ignore symbols: "Sam's Cantina", "Sams Golf Shop"

In our domain, the ignoring symbols causes some issues:

include symbols: "1/2/3", "1/2/5", "1/4", "12/4"
ignore symbols: "1/2/3", "12/4", "1/2/5", "1/4" (think alphabetic sort: "123", "125", "14", 124")

If you are ordering them to put into a tree, then this gets confusing and
can result in children coming back out of order. This is tricky for arrange.

An option was introduced for the case when symbols are not considered. It essentially
'join' the strings together to simulate ignoring the symbols.

Also do remember, that this is sorting alphabetically rather than numerically. So 14 > 123.
2021-05-21 19:43:42 -04:00
Keenan Brock d7aa7209ec upgrade mysql 2021-05-21 19:30:00 -04:00
d-m-u 824e8e6464 change the valid to not only check the entire object 2021-05-20 11:32:02 -04:00
Keenan Brock 0a1337e41e
Merge pull request #537 from d-m-u/github_actions
[wip] add github actions test skeleton
2021-05-12 17:08:58 -04:00
d-m-u 69f2324d09 add github actions skeleton 2021-05-11 21:14:18 -04:00
Keenan Brock 7f78f812ec
Merge pull request #536 from kbrock/missing_parent
Missing parent
2021-04-20 13:35:49 -04:00
Keenan Brock 3171caf5e8 dont throw error for invalid parent
resolves https://github.com/stefankroes/ancestry/issues/523

if an invalid value is in the parent_id, `parent` will return nil not throw error
2021-04-20 13:28:04 -04:00
Keenan Brock 54e7d64abc set default testing case to rails 6 2021-04-16 13:38:23 -04:00
Keenan Brock 2ff497e3d6 fix attribution 2021-04-12 22:21:53 -04:00
Keenan Brock 463a91abe3 update build versions
upgrading rails versions used to build that handle the
recently found cve. This is not vulnerable, but best to use
the suggested version

build with ruby 2.6. Ancestry should still work fine with ruby 2.5
but the version is EOL.
2021-04-12 22:00:24 -04:00
Keenan Brock c06c82908b
Merge pull request #533 from d-m-u/update_ar
update to 5.2.4.5
2021-04-12 21:26:29 -04:00
Keenan Brock 1f17b64b04 remaning v4 changes 2021-04-12 21:25:20 -04:00
d-m-u 890b9e1105 update to 5.2.4.5 2021-04-10 08:18:30 -04:00
Keenan Brock f70a056de6
Merge pull request #530 from d-m-u/adding_changelog_recent_april_21
update changelog
2021-04-07 23:15:51 -04:00
Keenan Brock a208f32ad2
Merge pull request #522 from forever-inc/rails-6-deprecation-warnings
Fixes Rails 6 Scoping Deprecation Warnings
2021-04-07 22:59:46 -04:00
d-m-u 8ce5ee5f7e update changelog 2021-04-02 17:54:40 -04:00
Keenan Brock 5e7d71dcf7
Merge pull request #528 from kbrock/pr-525
add: rails 6.1 and ruby 3
2021-02-05 15:31:40 -05:00
Daniel Smith dfb4829e23 add: rails 6.1 and ruby 3 2021-02-05 12:16:52 -05:00
Keenan Brock 71fe704279
Merge pull request #526 from d-m-u/drop_ar_closed_interval_five_one_five_two
drop support for 5.0 and 5.1
2021-02-04 13:01:33 -05:00
Keenan Brock 560770d9d2
Merge pull request #527 from thecartercenter/master
Fix order_by_ancestry_and for ActiveRecord 6.1
2021-02-03 14:03:22 -05:00
Tom Smyth 201e59bc75
Fix order_by_ancestry_and for ActiveRecord 6.1
Restored the second arg to `reorder`
2021-01-30 16:01:15 -05:00
d-m-u d39ca3b99f drop support for five oh and five one 2021-01-21 13:31:58 -05:00
Keenan Brock 4418715351
Merge pull request #524 from d-m-u/minor_formatting_nits
minor formatting nits
2021-01-04 15:31:32 -05:00
d-m-u 421826b67c use parens for args with blocks 2021-01-01 00:19:02 -05:00
d-m-u 10d386b7d5 use underscores for unused block args 2020-12-31 22:32:49 -05:00
d-m-u 78011f72bd fix end alignments 2020-12-31 22:19:07 -05:00
Chris Ryan 290a391807 Fixes Rails 6 Scoping Deprecation Warnings 2020-12-16 11:14:21 -05:00
Keenan Brock efa912d080
Merge pull request #519 from kbrock/has_parent
reduce the number of concepts
2020-10-31 21:56:28 -04:00
Keenan Brock 3a3a5bc728
Merge pull request #518 from amatsuda/send_extend
No need to send a public method "Object#extend"
2020-10-31 21:07:37 -04:00
Keenan Brock 1fc26fb3ae reduce the number of concepts
don't need parent_id? and has_parent?
also merging our usage of ancestors? and has_parent? because they are the same
2020-10-30 21:46:22 -04:00
Akira Matsuda a6bf43683e No need to send a public method "Object#extend" 2020-10-18 21:24:31 +09:00
Keenan Brock b8f241fb1e
Merge pull request #517 from d-m-u/patch-2
remove sudo:false from gemfile
2020-10-16 12:24:32 -04:00
d-m-u 0b9f9e1aad
remove sudo:false from gemfile
I don't think we need it here, either.
2020-10-16 09:51:42 -04:00
Keenan Brock 93eef2a669
Merge pull request #516 from pustomytnyk/patch-1
Mention counter_cache option
2020-10-16 02:45:25 -04:00
Roman Sokhan ea36744e94
Mention counter_cache option 2020-10-15 02:31:17 +03:00
Keenan Brock e35a95c6eb
Merge pull request #515 from d-m-u/patch-2
fix missing changelog links
2020-10-10 23:47:48 -04:00
d-m-u 147a28d78e Update CHANGELOG.md
add links to last two releases and change the `<small>` tags to the only thing i could get to work
2020-10-09 19:24:27 -04:00
Keenan Brock cf7c8380a0
Merge pull request #514 from kbrock/drop_42
drop rails 4.x
2020-10-08 23:19:48 -04:00
Keenan Brock 8b53592db9 drop rails 4.x 2020-10-08 10:57:47 -04:00
Keenan Brock 8b8bb233d3 Merge branch '3-2-stable' into master 2020-10-07 20:13:45 -04:00
Keenan Brock d888c03ed6
Merge pull request #501 from d-m-u/remove_sub_four_ar_check
remove code for old activerecord versions
2020-09-25 18:16:54 -04:00
Keenan Brock 4db1a520b3 Merge branch 'updates_321' into master 2020-09-23 22:57:51 -04:00
d-m-u 006f1d1599 always require minitest/autorun
we're past needing the version check
2020-08-10 01:14:57 -04:00
d-m-u 331793043b remove monkey patch and primary_key_is_an_integer? for AR < 4
do we still need either of these?

i think we're locked to >= 4.2
2020-08-10 01:13:56 -04:00
68 changed files with 3089 additions and 1127 deletions

View File

@ -1 +0,0 @@
service_name: travis-ci

6
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

126
.github/workflows/run_test_suite.yml vendored Normal file
View File

@ -0,0 +1,126 @@
name: run-test-suite
on:
push:
branches: [ master, 4-3-stable ]
pull_request:
branches: [ master, 4-3-stable ]
jobs:
test:
services:
# https://github.com/docker-library/docs/blob/master/postgres/README.md
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: password
POSTGRES_DB: ancestry_test
ports:
- "5432:5432"
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: ancestry_test
ports:
- "3306:3306"
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# this matrix is driven by these sources:
# - https://www.fastruby.io/blog/ruby/rails/versions/compatibility-table.html
# - https://www.ruby-lang.org/en/downloads/branches/
# - https://guides.rubyonrails.org/maintenance_policy.html
format: [materialized_path, materialized_path2]
activerecord: [70, 71, 72]
ruby: [3.2, 3.3]
# additional tests
include:
# EOL 6/2022 (ruby >= 2.2.2, <2.7)
- ruby: 2.6
activerecord: 52
# EOL 2023
- ruby: 2.7
activerecord: 60
# rails 6.1 and 7.0 have different ruby versions
- ruby: 2.7
activerecord: 61
- ruby: "3.0"
activerecord: 61
env:
# for the pg cli (psql, pg_isready) and possibly rails
PGHOST: 127.0.0.1 # container is mapping it locally
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
# for the mysql cli (mysql, mysqladmin)
MYSQL_HOST: 127.0.0.1
MYSQL_PWD: password
# for rails tests (from matrix)
BUNDLE_GEMFILE: gemfiles/gemfile_${{ matrix.activerecord }}.gemfile
FORMAT: ${{ matrix.format }}
steps:
- name: checkout code
uses: actions/checkout@v4
- name: setup Ruby
# https://github.com/ruby/setup-ruby#versioning
# runs 'bundle install' and caches installed gems automatically
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: run sqlite tests
env:
DB: sqlite3
run: |
bundle exec rake
- name: run pg tests
env:
DB: pg
run: |
bundle exec rake
- name: run pg tests (UPDATE_STRATEGY=sql)
env:
DB: pg
UPDATE_STRATEGY: sql
run: |
bundle exec rake
if: ${{ matrix.activerecord != 52 }}
- name: run pg tests (ANCESTRY_COLUMN=ancestry_alt)
env:
DB: pg
ANCESTRY_COLUMN: ancestry_alt
run: |
bundle exec rake
- name: run pg tests (UPDATE_STRATEGY=sql ANCESTRY_COLUMN=ancestry_alt)
env:
DB: pg
ANCESTRY_COLUMN: ancestry_alt
UPDATE_STRATEGY: sql
run: |
bundle exec rake
FORMAT=${{ matrix.format }} UPDATE_STRATEGY=sql bundle exec rake
if: ${{ matrix.activerecord != 52 }}
- name: run mysql tests
env:
DB: mysql2
run: |
bundle exec rake
- name: run mysql tests (ANCESTRY_COLUMN_TYPE=binary)
env:
DB: mysql2
ANCESTRY_COLUMN_TYPE: binary
run: |
bundle exec rake

77
.rubocop.yml Normal file
View File

@ -0,0 +1,77 @@
AllCops:
NewCops: enable
SuggestExtensions: false
TargetRubyVersion: 3.3
Gemspec/DevelopmentDependencies:
Enabled: false
Layout/HashAlignment:
Enabled: false
Layout/LineLength:
Enabled: false
Layout/SpaceInsideHashLiteralBraces:
EnforcedStyle: no_space
Layout/SpaceAroundOperators:
EnforcedStyleForExponentOperator: space
Metrics/AbcSize:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/ClassLength:
Enabled: false
Metrics/CyclomaticComplexity:
Enabled: false
Metrics/MethodLength:
Enabled: false
Metrics/ModuleLength:
Enabled: false
Metrics/PerceivedComplexity:
Enabled: false
Naming/PredicateName:
Enabled: false
Naming/VariableNumber:
Enabled: false
Style/Documentation:
Enabled: false
Style/ExpandPathArguments:
Enabled: false
Style/GuardClause:
Enabled: false
Style/HashSyntax:
Enabled: false
Style/IfUnlessModifier:
Enabled: false
Style/Lambda:
Enabled: false
Style/MultilineIfModifier:
Enabled: false
Style/NegatedIf:
Enabled: false
Style/Next:
Enabled: false
Style/NumericPredicate:
Enabled: false
Style/ParallelAssignment:
Enabled: false
Style/PercentLiteralDelimiters:
Enabled: false
Style/StringLiterals:
Enabled: false
Style/StringLiteralsInInterpolation:
Enabled: false
Style/SymbolArray:
Enabled: false
# specs
Layout/ExtraSpacing:
Exclude: [ "test/**/*" ]
Style/TrailingUnderscoreVariable:
Exclude: [ "test/**/*" ]
Layout/SpaceBeforeFirstArg:
Exclude: [ "test/**/*" ]
Layout/SpaceAroundOperators:
Exclude: [ "test/**/*" ]

View File

@ -1,33 +0,0 @@
sudo: false
language: ruby
cache: bundler
rvm:
- 2.5
- 2.6
- 2.7
env:
- DB=pg
- DB=mysql2
- DB=sqlite3
gemfile:
- gemfiles/gemfile_42.gemfile
- gemfiles/gemfile_50.gemfile
- gemfiles/gemfile_51.gemfile
- gemfiles/gemfile_52.gemfile
- gemfiles/gemfile_60.gemfile
jobs:
exclude:
- rvm: 2.7
gemfile: gemfiles/gemfile_42.gemfile
services:
- mysql
- postgresql
before_script:
- mysql -e 'create database ancestry_test;' || true
- psql -c 'create database ancestry_test;' -U postgres || true

View File

@ -1,24 +1,19 @@
%w(4.2.10 5.0.7 5.1.7 5.2.3 6.0.0).each do |ar_version|
# frozen_string_literal: true
# on a mac using:
# bundle config --global build.mysql2 "--with-mysql-dir=$(brew --prefix mysql)"
%w[5.2.8 6.0.6 6.1.7 7.0.8 7.1.3 7.2.1].each do |ar_version|
appraise "gemfile-#{ar_version.split('.').first(2).join}" do
gem "activerecord", ar_version
if ar_version < "5.0"
gem "pg", "0.18.4"
gem 'activerecord', "~> #{ar_version}"
# active record 5.2 uses ruby 2.6
# active record 6.x uses ruby 2.7 (sometimes 3.0)
# so we are targeting the ruby version indirectly through active record
if ar_version < "7.0"
gem "sqlite3", "~> 1.6.9"
else
gem "pg"
end
# rails 5.0 only supports 'mysql2' driver
# rails 4.2 supports both ( but travis complains with 4.2 and mysql2)
if ar_version < "4.2"
gem "mysql"
elsif ar_version < "5.0"
gem "mysql2", '~> 0.4.0'
else
gem "mysql2"
end
if ar_version < "5.2"
gem "sqlite3", "~> 1.3.13"
else
gem "sqlite3"
# sqlite3 v 2.0 is causing trouble with rails
gem "sqlite3", "< 2.0"
end
end
end

View File

@ -3,15 +3,120 @@
Doing our best at supporting [SemVer](http://semver.org/) with
a nice looking [Changelog](http://keepachangelog.com).
## Version [HEAD] <small>now</small>
## Version [HEAD] <sub><sup>Unreleased</sub></sup>
* dropped support for rails 4.2 and 5.0
* Introduce `orphan_strategy: :none` [#658](https://github.com/stefankroes/ancestry/pull/658)
* Introduce `rebuild_counter_cache!` to reset counter caches. [#663](https://github.com/stefankroes/ancestry/pull/663) [#668](https://github.com/stefankroes/ancestry/pull/668) (thx @RongRongTeng)
* Documentation fixes [#664](https://github.com/stefankroes/ancestry/pull/664) [#667](https://github.com/stefankroes/ancestry/pull/667) (thx @motokikando, @onerinas)
* Introduce `build_cache_depth_sql!`, a sql alternative to `build_cache_depth` [#654](https://github.com/stefankroes/ancestry/pull/654)
* Drop `ancestry_primary_key_format` [#649](https://github.com/stefankroes/ancestry/pull/649)
* When destroying orphans, going from leafs up to node [#635](https://github.com/stefankroes/ancestry/pull/635) (thx @brendon)
* Changed config setters to class readers [#633](https://github.com/stefankroes/ancestry/pull/633) (thx @kshurov)
* Split apply_orphan_strategy into multiple methods [#632](https://github.com/stefankroes/ancestry/pull/633) [#633](https://github.com/stefankroes/ancestry/pull/617)
## Versions [3.2.1] <small>2020-09-23</small>
#### Notable features
Depth scopes now work without `cache_depth`. But please only use this in background
jobs. If you need to do this in the ui, please use `cache_depth`.
`build_cache_depth_sql!` is quicker than `build_cache_depth!` (1 query instead of N+1 queries).
#### Deprecations
- Option `:depth_cache_column` is going away.
Please use a single key instead: `cache_depth: :depth_cach_column_name`.
`cache_depth: true` still defaults to `ancestry_depth`.
#### Breaking Changes
* Options are no longer set via class methods. Using `has_ancestry` is now the only way to enable these features.
These are all not part of the public API.
* These are now class level read only accessors
- `ancestry_base_class` (introduced 1.1, removed by #633)
- `ancestry_column` (introduced 1.2, removed by #633)
- `ancestry_delimiter` (introduced 4.3.0, removed by #633)
- `depth_cache_column` (introduced 4.3.0, removed by #654)
* These no longer have any accessors
- `ancestry_format` (introduced 4.3.0, removed by #654)
- `orphan_strategy` (introduced 1.1, removed by #617)
- `ancestry_primary_key_format` (introduced 4.3.0, removed by #649)
- `touch_ancestors` (introduced 2.1, removed by TODO)
* These are seen as internal and may go away:
- `apply_orphan_strategy` Please use `orphan_strategy: :none` and a custom `before_destory` instead.
## Version [4.3.3] <sub><sup>2023-04-01</sub></sup>
* Fix: sort_by_ancesty with custom ancestry_column [#656](https://github.com/stefankroes/ancestry/pull/656) (thx @mitsuru)
## Version [4.3.2] <sub><sup>2023-03-25</sub></sup>
* Fix: added back fields that were removed in #589 [#647](https://github.com/stefankroes/ancestry/pull/647) (thx @rastamhadi)
- path_ids_in_database
## Version [4.3.1] <sub><sup>2023-03-19</sub></sup>
* Fix: added back fields that were removed in #589 [#637](https://github.com/stefankroes/ancestry/pull/637) (thx @znz)
- ancestor_ids_in_database
- parent_id_in_database
## Version [4.3.0] <sub><sup>2023-03-09</sub></sup>
* Fix: materialized_path2 strategy [#597](https://github.com/stefankroes/ancestry/pull/597) (thx @kshnurov)
* Fix: descendants ancestry is now updated in after_update callbacks [#589](https://github.com/stefankroes/ancestry/pull/589) (thx @kshnurov)
* Document updated grammar [#594](https://github.com/stefankroes/ancestry/pull/594) (thx @omarr-gamal)
* Documented `update_strategy` [#588](https://github.com/stefankroes/ancestry/pull/588) (thx @victorfgs)
* Fix: fixed has_parent? when non-default primary id [#585](https://github.com/stefankroes/ancestry/pull/585) (thx @Zhong-z)
* Documented column collation and testing [#601](https://github.com/stefankroes/ancestry/pull/601) [#607](https://github.com/stefankroes/ancestry/pull/607) (thx @kshnurov)
* Added initializer with default_ancestry_format [#612](https://github.com/stefankroes/ancestry/pull/612) [#613](https://github.com/stefankroes/ancestry/pull/613)
* ruby 3.2 support [#596](https://github.com/stefankroes/ancestry/pull/596) (thx @petergoldstein)
* Reduce memory for sort_by_ancestry [#415](https://github.com/stefankroes/ancestry/pull/415)
#### Notable features
Default configuration values are provided for a few options: `update_strategy`, `ancestry_format`, and `primary_key_format`.
These can be set in an initializer via `Ancestry.default_{ancestry_format} = value`
A new `ancestry_format` of `:materialized_path2` formats the ancestry column with leading and trailing slashes.
It shows promise to make the `ancestry` field more sql friendly.
Both of these are better documented in [the readme](/README.md).
#### Breaking changes
- `ancestry_primary_key_format` is now specified or a single key not the whole regular expression.
We used to accept `/\A[0-9]+(/[0-9]+)*` or `'[0-9]'`, but now we only accept `'[0-9]'`.
## Version [4.2.0] <sub><sup>2022-06-09</sub></sup>
* added strategy: materialized_path2 [#571](https://github.com/stefankroes/ancestry/pull/571)
* Added tree_view method [#561](https://github.com/stefankroes/ancestry/pull/561) (thx @bizcho)
* Fixed bug when errors would not undo callbacks [#566](https://github.com/stefankroes/ancestry/pull/566) (thx @daniloisr)
* ruby 3.0 support
* rails 7.0 support (thx @chenillen, @petergoldstein)
* Documentation fixes (thx @benkoshy, @mijoharas)
## Version [4.1.0] <sub><sup>2021-06-25</sub></sup>
* `parent` with an invalid id now returns nil (thx @vanboom)
* `root` returns self if ancestry is invalid (thx @vanboom)
* fix case where invalid object prevented ancestry updates (thx @d-m-u)
* oracleenhanced uses nulls first for sorting (thx @lual)
* fix counter cache and STI (thx @mattvague)
## Version [4.0.0] <sub><sup>2021-04-12</sub></sup>
* dropped support for rails 4.2 and 5.0 (thx @d-m-u)
* better documentation counter cache option (thx @pustomytnyk)
* clean up code (thx @amatsuda @d-m-u)
* fixed rails 6.1 support (thx @cmr119 @d-staehler @danini-the-panini )
* phasing out `parent_id?`, `ancestors?` and using `has_parent?` instead
* fixed postgres order bug on rails 6.2 and higher (thx @smoyt)
## Version [3.2.1] <sub><sup>2020-09-23</sub></sup>
* fixed gemspec to include locales and pg (thx @HectorMF)
## Versions [3.2.0] <small>2020-09-23</small>
## Version [3.2.0] <sub><sup>2020-09-23</sub></sup>
* introduce i18n
* pg sql optimization for ancestry changes (thx @suonlight and @geis)
@ -20,7 +125,7 @@ a nice looking [Changelog](http://keepachangelog.com).
* able to convert to ancestry from a parent_id column with a different name
* documentation fixes for better diagrams and grammar (thx @dtamais, @d-m-u, and @CamilleDrapier)
## Versions [3.1.0] <small>2020-08-03</small>
## Version [3.1.0] <sub><sup>2020-08-03</sub></sup>
* `:primary_key_format` method lets you change syntax. good for uuids.
* changed code from being `ancestry` string to `ancestry_ids` focused. May break monkey patches.
@ -29,16 +134,16 @@ a nice looking [Changelog](http://keepachangelog.com).
* Better documentation for relationships (thnx @dtamai and @d-m-u)
* Fix creating children in `after_*` callbacks (thx @jstirk)
## Version [3.0.7] <small>2018-11-06</small>
## Version [3.0.7] <sub><sup>2018-11-06</sub></sup>
* Fixed rails 5.1 change detection (thx @jrafanie)
* Introduce counter cache (thx @hw676018683)
## Version [3.0.6] <small>2018-11-06</small>
## Version [3.0.6] <sub><sup>2018-11-06</sub></sup>
* Fixed rails 4.1 version check (thx @myxoh)
## Version [3.0.5] <small>2018-11-06</small>
## Version [3.0.5] <sub><sup>2018-11-06</sub></sup>
## Changed
@ -49,14 +154,14 @@ a nice looking [Changelog](http://keepachangelog.com).
* Reduced memory footprint of parsing ancestry column (thx @NickLaMuro)
## Version [3.0.4] <small>2018-10-27</small>
## Version [3.0.4] <sub><sup>2018-10-27</sub></sup>
## Fixes
* Properly detects non-integer columns (thx @adam101)
* Arrange no longer drops nodes due to missing parents (thx @trafium)
## Version [3.0.3] <small>2018-10-23</small>
## Version [3.0.3] <sub><sup>2018-10-23</sub></sup>
This branch (3.x) should still be compatible with rails 3 and 4.
Rails 5.1 and 5.2 support were introduced in this version, but ongoing support
@ -72,7 +177,7 @@ has been moved to ancestry 4.0
* Dropped builds for ruby 1.9.3, 2.0, 2.1, and 2.2
* Dropped builds for Rails 3.x and 4.x (will use Active Record `or` syntax)
## Version [3.0.2] <small>2018-04-24</small>
## Version [3.0.2] <sub><sup>2018-04-24</sub></sup>
## Fixes
@ -82,7 +187,7 @@ has been moved to ancestry 4.0
* added missing `Ancestry::version`
* added Rails 5.2 support (thx @jjuliano)
## Version [3.0.1] <small>2017-07-05</small>
## Version [3.0.1] <sub><sup>2017-07-05</sub></sup>
## Fixes
@ -93,7 +198,7 @@ has been moved to ancestry 4.0
* fixed tests on mysql 5.7 and rails 3.2
* Dropped 3.1 scope changes
## Version [3.0.0] <small>2017-05-18</small>
## Version [3.0.0] <sub><sup>2017-05-18</sub></sup>
## Changed
@ -109,7 +214,7 @@ has been moved to ancestry 4.0
* Properly touches parents when different class for STI (thx @samtgarson)
* Fixed issues with parent_id (only present on master) (thx @domcleal)
## Version [2.2.2] <small>2016-11-01</small>
## Version [2.2.2] <sub><sup>2016-11-01</sub></sup>
### Changed
@ -117,7 +222,7 @@ has been moved to ancestry 4.0
* Fixed bug with explicit order clauses (introduced in 2.2.0)
* No longer load schema on `has_ancestry` load (thx @ledermann)
## Version [2.2.1] <small>2016-10-25</small>
## Version [2.2.1] <sub><sup>2016-10-25</sub></sup>
Sorry for blip, local master got out of sync with upstream master.
Missed 2 commits (which are feature adds)
@ -126,7 +231,7 @@ Missed 2 commits (which are feature adds)
* Use like (vs ilike) for rails 5.0 (performance enhancement)
* Use `COALESCE` for sorting on pg, mysql, and sqlite vs `CASE`
## Version [2.2.0] <small>2016-10-25</small>
## Version [2.2.0] <sub><sup>2016-10-25</sub></sup>
### Added
* Predicates for scopes: e.g.: `ancestor_of?`, `parent_of?` (thx @neglectedvalue)
@ -141,7 +246,7 @@ Missed 2 commits (which are feature adds)
* Upgrading tests for ruby versions (thx @brocktimus, @fryguy, @yui-knk)
* Fix non-default ancestry not getting used properly (thx @javiyu)
## Version [2.1.0] <small>2014-04-16</small>
## Version [2.1.0] <sub><sup>2014-04-16</sub></sup>
* Added arrange_serializable (thx @krishandley, @chicagogrrl)
* Add the :touch to update ancestors on save (thx @adammck)
* Change conditions into arel (thx @mlitwiniuk)
@ -150,7 +255,7 @@ Missed 2 commits (which are feature adds)
* Performance tweak (thx @mjc)
* Improvements to organization (thx @xsuchy, @ryakh)
## Version [2.0.0] <small>2013-05-17</small>
## Version [2.0.0] <sub><sup>2013-05-17</sub></sup>
* Removed rails 2 compatibility
* Added table name to condition constructing methods (thx @aflatter)
* Fix depth_cache not being updated when moving up to ancestors (thx @scottatron)
@ -162,31 +267,31 @@ Missed 2 commits (which are feature adds)
* New adopt strategy (thx unknown)
* Many more improvements
## Version [1.3.0] <small>2012-05-04</small>
## Version [1.3.0] <sub><sup>2012-05-04</sub></sup>
* Ancestry now ignores default scopes when moving or destroying nodes, ensuring tree consistency
* Changed ActiveRecord dependency to 2.3.14
## Version [1.2.5] <small>2012-03-15</small>
## Version [1.2.5] <sub><sup>2012-03-15</sub></sup>
* Fixed warnings: "parenthesize argument(s) for future version"
* Fixed a bug in the restore_ancestry_integrity! method (thx Arthur Holstvoogd)
## Version [1.2.4] <small>2011-04-22</small>
## Version [1.2.4] <sub><sup>2011-04-22</sub></sup>
* Prepended table names to column names in queries (thx @raelik)
* Better check to see if acts_as_tree can be overloaded (thx @jims)
* Performance inprovements (thx @kueda)
## Version [1.2.3] <small>2010-10-28</small>
## Version [1.2.3] <sub><sup>2010-10-28</sub></sup>
* Fixed error with determining ActiveRecord version
* Added option to specify :primary_key_format (thx @rolftimmermans)
## Version [1.2.2] <small>2010-10-24</small>
## Version [1.2.2] <sub><sup>2010-10-24</sub></sup>
* Fixed all deprecation warnings for rails 3.0.X
* Added `:report` option to `check_ancestry_integrity!`
* Changed ActiveRecord dependency to 2.2.2
* Tested and fixed for ruby 1.8.7 and 1.9.2
* Changed usage of `update_attributes` to `update_attribute` to allow ancestry column protection
## Version [1.2.0] <small>2009-11-07</small>
## Version [1.2.0] <sub><sup>2009-11-07</sub></sup>
* Removed some duplication in has_ancestry
* Cleaned up plugin pattern according to http://yehudakatz.com/2009/11/12/better-ruby-idioms/
* Moved parts of ancestry into seperate files
@ -197,23 +302,23 @@ Missed 2 commits (which are feature adds)
* Updated ordered_by_ancestry scope to support Microsoft SQL Server
* Added empty hash as parameter to exists? calls for older ActiveRecord versions
## Version [1.1.4] <small>2009-11-07</small>
## Version [1.1.4] <sub><sup>2009-11-07</sub></sup>
* Thanks to a patch from tom taylor, Ancestry now works with different primary keys
## Version [1.1.3] <small>2009-11-01</small>
## Version [1.1.3] <sub><sup>2009-11-01</sub></sup>
* Fixed a pretty bad bug where several operations took far too many queries
## Version [1.1.2] <small>2009-10-29</small>
## Version [1.1.2] <sub><sup>2009-10-29</sub></sup>
* Added validation for depth cache column
* Added STI support (reported broken)
## Version [1.1.1] <small>2009-10-28</small>
## Version [1.1.1] <sub><sup>2009-10-28</sub></sup>
* Fixed some parentheses warnings that where reported
* Fixed a reported issue with arrangement
* Fixed issues with ancestors and path order on postgres
* Added ordered_by_ancestry scope (needed to fix issues)
## Version [1.1.0] <small>2009-10-22</small>
## Version [1.1.0] <sub><sup>2009-10-22</sub></sup>
* Depth caching (and cache rebuilding)
* Depth method for nodes
* Named scopes for selecting by depth
@ -236,7 +341,7 @@ Missed 2 commits (which are feature adds)
* Removed rails specific init
* Removed uninstall script
## Version 1.0.0 <small>2009-10-16</small>
## Version 1.0.0 <sub><sup>2009-10-16</sub></sup>
* Initial version
* Tree building
* Tree navigation
@ -248,8 +353,16 @@ Missed 2 commits (which are feature adds)
* Validations
[HEAD]: https://github.com/stefankroes/ancestry/compare/v3.2.0...HEAD
[3.1.0]: https://github.com/stefankroes/ancestry/compare/v3.1.0...v3.2.0
[HEAD]: https://github.com/stefankroes/ancestry/compare/v4.3.0...HEAD
[4.3.3]: https://github.com/stefankroes/ancestry/compare/v4.3.2...v4.3.3
[4.3.2]: https://github.com/stefankroes/ancestry/compare/v4.3.1...v4.3.2
[4.3.1]: https://github.com/stefankroes/ancestry/compare/v4.3.0...v4.3.1
[4.3.0]: https://github.com/stefankroes/ancestry/compare/v4.2.0...v4.3.0
[4.2.0]: https://github.com/stefankroes/ancestry/compare/v4.1.0...v4.2.0
[4.1.0]: https://github.com/stefankroes/ancestry/compare/v4.0.0...v4.1.0
[4.0.0]: https://github.com/stefankroes/ancestry/compare/v3.2.1...v4.0.0
[3.2.1]: https://github.com/stefankroes/ancestry/compare/v3.2.0...v3.2.1
[3.2.0]: https://github.com/stefankroes/ancestry/compare/v3.1.0...v3.2.0
[3.1.0]: https://github.com/stefankroes/ancestry/compare/v3.0.7...v3.1.0
[3.0.7]: https://github.com/stefankroes/ancestry/compare/v3.0.6...v3.0.7
[3.0.6]: https://github.com/stefankroes/ancestry/compare/v3.0.5...v3.0.6

128
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
keenan@thebrocks.net.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@ -1,9 +1,10 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gemspec
gem "activerecord", '~> 5.2'
gem "coveralls", require: false
gem "activerecord", "~> 7.2"
gem "mysql2"
gem "pg"
gem "sqlite3"
gem "sqlite3", "~> 1.6.9"

468
README.md
View File

@ -1,30 +1,61 @@
[![Build Status](https://travis-ci.org/stefankroes/ancestry.svg?branch=master)](https://travis-ci.org/stefankroes/ancestry) [![Coverage Status](https://coveralls.io/repos/stefankroes/ancestry/badge.svg)](https://coveralls.io/r/stefankroes/ancestry) [![Gitter](https://badges.gitter.im/Join+Chat.svg)](https://gitter.im/stefankroes/ancestry?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Security](https://hakiri.io/github/stefankroes/ancestry/master.svg)](https://hakiri.io/github/stefankroes/ancestry/master)
[![Gitter](https://badges.gitter.im/Join+Chat.svg)](https://gitter.im/stefankroes/ancestry?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
# Ancestry
Ancestry is a gem that allows the records of a Ruby on Rails
ActiveRecord model to be organised as a tree structure (or hierarchy). It employs
the materialised path pattern and exposes all the standard tree structure
relations (ancestors, parent, root, children, siblings, descendants), allowing all
of them to be fetched in a single SQL query. Additional features include STI
support, scopes, depth caching, depth constraints, easy migration from older
gems, integrity checking, integrity restoration, arrangement of
(sub)trees into hashes, and various strategies for dealing with orphaned
records.
## Overview
NOTE:
Ancestry is a gem that allows rails ActiveRecord models to be organized as
a tree structure (or hierarchy). It employs the materialized path pattern
which allows operations to be performed efficiently.
# Features
There are a few common ways of storing hierarchical data in a database:
materialized path, closure tree table, adjacency lists, nested sets, and adjacency list with recursive queries.
## Features from Materialized Path
- Store hierarchy in an easy to understand format. (e.g.: `/1/2/3/`)
- Store hierarchy in the original table with no additional tables.
- Single SQL queries for relations (`ancestors`, `parent`, `root`, `children`, `siblings`, `descendants`)
- Single query for creating records.
- Moving/deleting nodes only affect child nodes (rather than updating all nodes in the tree)
## Features from Ancestry gem Implementation
- relations are implemented as `scopes`
- `STI` support
- Arrangement of subtrees into hashes
- Multiple strategies for querying materialized_path
- Multiple strategies for dealing with orphaned records
- depth caching
- depth constraints
- counter caches
- Multiple strategies for moving nodes
- Easy migration from `parent_id` based gems
- Integrity checking
- Integrity restoration
- Most queries use indexes on `id` or `ancestry` column. (e.g.: `LIKE '#{ancestry}/%'`)
Since a Btree index has a limitation of 2704 characters for the `ancestry` column,
the maximum depth of an ancestry tree is 900 items at most. If ids are 4 digits long,
then the max depth is 540 items.
When using `STI` all classes are returned from the scopes unless you specify otherwise using `where(:type => "ChildClass")`.
## Supported Rails versions
- Ancestry 2.x supports Rails 4.1 and earlier
- Ancestry 3.x supports Rails 5.0 and 4.2
- Ancestry 4.0 only supports rails 5.0 and higher
- Ancestry 3.x supports Rails 4.2 and 5.0
- Ancestry 4.x supports Rails 5.2 through 7.0
- Ancestry 5.0 supports Rails 6.0 and higher
Rails 5.2 with `update_strategy=ruby` is still being tested in 5.0.
# Installation
Follow these simple steps to apply Ancestry to any ActiveRecord model:
Follow these steps to apply Ancestry to any ActiveRecord model:
## Install
* Add to Gemfile:
## Add to Gemfile
```ruby
# Gemfile
@ -32,29 +63,49 @@ Follow these simple steps to apply Ancestry to any ActiveRecord model:
gem 'ancestry'
```
* Install required gems:
```bash
$ bundle install
```
## Add ancestry column to your table
* Create migration:
```bash
$ rails g migration add_ancestry_to_[table] ancestry:string:index
$ rails g migration add_[ancestry]_to_[table] ancestry:string:index
```
* Migrate your database:
```ruby
class AddAncestryToTable < ActiveRecord::Migration[6.1]
def change
change_table(:table) do |t|
# postgres
t.string "ancestry", collation: 'C', null: false
t.index "ancestry"
# mysql
t.string "ancestry", collation: 'utf8mb4_bin', null: false
t.index "ancestry"
end
end
end
```
There are additional options for the columns in [Ancestry Database Column](#ancestry-database-column) and
an explanation for `opclass` and `collation`.
```bash
$ rake db:migrate
```
## Configure ancestry defaults
```ruby
# config/initializers/ancestry.rb
# use the newer format
Ancestry.default_ancestry_format = :materialized_path2
# Ancestry.default_update_strategy = :sql
```
## Add ancestry to your model
* Add to app/models/[model.rb]:
```ruby
# app/models/[model.rb]
@ -66,22 +117,14 @@ end
Your model is now a tree!
# Using acts_as_tree instead of has_ancestry
In version 1.2.0, the **acts_as_tree** method was **renamed to has_ancestry**
in order to allow usage of both the acts_as_tree gem and the ancestry gem in a
single application. The `acts_as_tree` method will continue to be supported in the future.
# Organising records into a tree
You can use the parent attribute to organise your records into a tree. If you
have the id of the record you want to use as a parent and don't want to fetch
it, you can also use parent_id. Like any virtual model attributes, parent and
parent_id can be set using parent= and parent_id= on a record or by including
them in the hash passed to new, create, create!, update_attributes and
update_attributes!. For example:
You can use `parent_id` and `parent` to add a node into a tree. They can be
set as attributes or passed into methods like `new`, `create`, and `update`.
`TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky')`.
```ruby
TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky')
```
Children can be created through the children relation on a node: `node.children.create :name => 'Stinky'`.
@ -108,30 +151,50 @@ The yellow nodes are those returned by the method.
| includes self |self..indirects |root..self |
|`sibling_ids` |`subtree_ids` |`path_ids` |
|`has_siblings?` | | |
|`sibling_of?(node)` | | |
|`sibling_of?(node)` |`in_subtree_of?` | |
When using `STI` all classes are returned from the scopes unless you specify otherwise using `where(:type => "ChildClass")`.
<sup id="fn1">1. [other root records are considered siblings]<a href="#ref1" title="Jump back to footnote 1."></a></sup>
# `has_ancestry` options
# has_ancestry options
The has_ancestry method supports the following options:
The `has_ancestry` method supports the following options:
:ancestry_column Pass in a symbol to store ancestry in a different column
:orphan_strategy Instruct Ancestry what to do with children of a node that is destroyed:
:ancestry_column Column name to store ancestry
'ancestry' (default)
:ancestry_format Format for ancestry column (see Ancestry Formats section):
:materialized_path 1/2/3, root nodes ancestry=nil (default)
:materialized_path2 /1/2/3/, root nodes ancestry=/ (preferred)
:orphan_strategy How to handle children of a destroyed node:
:destroy All children are destroyed as well (default)
:rootify The children of the destroyed node become root nodes
:restrict An AncestryException is raised if any children exist
:adopt The orphan subtree is added to the parent of the deleted node
If the deleted node is Root, then rootify the orphan subtree
:cache_depth Cache the depth of each node in the 'ancestry_depth' column (default: false)
If you turn depth_caching on for an existing model:
- Migrate: add_column [table], :ancestry_depth, :integer, :default => 0
- Build cache: TreeNode.rebuild_depth_cache!
:depth_cache_column Pass in a symbol to store depth cache in a different column
:primary_key_format Supply a regular expression that matches the format of your primary key
By default, primary keys only match integers ([0-9]+)
:touch Instruct Ancestry to touch the ancestors of a node when it changes, to
invalidate nested key-based caches. (default: false)
:none skip this logic. (add your own `before_destroy`)
:cache_depth Cache the depth of each node: (See Depth Cache section)
false Do not cache depth (default)
true Cache depth in 'ancestry_depth'
String Cache depth in the column referenced
:primary_key_format Regular expression that matches the format of the primary key:
'[0-9]+' integer ids (default)
'[-A-Fa-f0-9]{36}' UUIDs
:touch Touch the ancestors of a node when it changes:
false don't invalid nested key-based caches (default)
true touch all ancestors of previous and new parents
:counter_cache Create counter cache column accessor:
false don't store a counter cache (default)
true store counter cache in `children_count`.
String name of column to store counter cache.
:update_strategy How to update descendants nodes:
:ruby All descendants are updated using the ruby algorithm. (default)
This triggers update callbacks for each descendant node
:sql All descendants are updated using a single SQL statement.
This strategy does not trigger update callbacks for the descendants.
This strategy is available only for PostgreSql implementations
Legacy configuration using `acts_as_tree` is still available. Ancestry defers to `acts_as_tree` if that gem is installed.
# (Named) Scopes
@ -163,7 +226,7 @@ It is possible thanks to some convenient rails magic to create nodes through the
# Selecting nodes by depth
With depth caching enabled (see has_ancestry options), an additional five named
With depth caching enabled (see [has_ancestry options](#has_ancestry-options)), an additional five named
scopes can be used to select nodes by depth:
before_depth(depth) Return nodes that are less deep than depth (node.depth < depth)
@ -183,16 +246,13 @@ can be fetched directly from the ancestry column without needing a query. Use
node.descendants(:from_depth => 2, :to_depth => 4)
node.subtree.from_depth(10).to_depth(12)
# STI support
To use with STI: create a STI inheritance hierarchy and build a tree from the different
classes/models. All Ancestry relations that were described above will return nodes of any model type. If
you do only want nodes of a specific subclass, a type condition is required.
# Arrangement
## `arrange`
A subtree can be arranged into nested hashes for easy navigation after database retrieval.
`TreeNode.arrange` could, for instance, return:
The resulting format is a hash of hashes
```ruby
{
@ -205,24 +265,22 @@ A subtree can be arranged into nested hashes for easy navigation after database
}
```
The `arrange` method can work on a scoped class (`TreeNode.find_by(:name => 'Crunchy').subtree.arrange`),
and can take ActiveRecord find options. If you want ordered hashes, pass the order to the method instead of
the scope as follows:
`TreeNode.find_by(:name => 'Crunchy').subtree.arrange(:order => :name)`.
The `arrange_serializable` method returns the arranged nodes as a nested array of hashes. Order
can be passed in the same fashion as to the `arrange` method:
`TreeNode.arrange_serializable(:order => :name)` The result can easily be serialized to json with `to_json`
or other formats. You can also supply your own serialization logic with blocks.
Using `ActiveModel` serializers:
`TreeNode.arrange_serializable { |parent, children| MySerializer.new(parent, children: children) }`.
Or plain hashes:
There are many ways to call `arrange`:
```ruby
TreeNode.find_by(:name => 'Crunchy').subtree.arrange
TreeNode.find_by(:name => 'Crunchy').subtree.arrange(:order => :name)
```
## `arrange_serializable`
If a hash of arrays is preferred, `arrange_serializable` can be used. The results
work well with `to_json`.
```ruby
TreeNode.arrange_serializable(:order => :name)
# use an active model serializer
TreeNode.arrange_serializable { |parent, children| MySerializer.new(parent, children: children) }
TreeNode.arrange_serializable do |parent, children|
{
my_id: parent.id,
@ -234,54 +292,232 @@ end
# Sorting
The `sort_by_ancestry` class method: `TreeNode.sort_by_ancestry(array_of_nodes)` can be used
to sort an array of nodes as if traversing in preorder. (Note that since materialised path
to sort an array of nodes as if traversing in preorder. (Note that since materialized path
trees do not support ordering within a rank, the order of siblings is
dependant upon their original array order.)
# Ancestry Database Column
## Collation Indexes
Sorry, using collation or index operator classes makes this a little complicated. The
root of the issue is that in order to use indexes, the ancestry column needs to
compare strings using ascii rules.
It is well known that `LIKE '/1/2/%'` will use an index because the wildcard (i.e.: `%`)
is on the right hand side of the `LIKE`. While that is true for ascii strings, it is not
necessarily true for unicode. Since ancestry only uses ascii characters, telling the database
this constraint will optimize the `LIKE` statements.
## Collation Sorting
As of 2018, standard unicode collation ignores punctuation for sorting. This ignores
the ancestry delimiter (i.e.: `/`) and returns data in the wrong order. The exception
being Postgres on a mac, which ignores proper unicode collation and instead uses
ISO-8859-1 ordering (read: ascii sorting).
Using the proper column storage and indexes will ensure that data is returned from the
database in the correct order. It will also ensure that developers on Mac or Windows will
get the same results as linux production servers, if that is your setup.
## Migrating Collation
If you are reading this and want to alter your table to add collation to an existing column,
remember to drop existing indexes on the `ancestry` column and recreate them.
## ancestry_format materialized_path and nulls
If you are using the legacy `ancestry_format` of `:materialized_path`, then you need to the
column to allow `nulls`. Change the column create accordingly: `null: true`.
Chances are, you can ignore this section as you most likely want to use `:materialized_path2`.
## Postgres Storage Options
### ascii field collation
The currently suggested way to create a postgres field is using `'C'` collation:
```ruby
t.string "ancestry", collation: 'C', null: false
t.index "ancestry"
```
### ascii index
If you need to use a standard collation (e.g.: `en_US`), then use an ascii index:
```ruby
t.string "ancestry", null: false
t.index "ancestry", opclass: :varchar_pattern_ops
```
This option is mostly there for users who have an existing ancestry column and are more
comfortable tweaking indexes rather than altering the ancestry column.
### binary column
When the column is binary, the database doesn't convert strings using locales.
Rails will convert the strings and send byte arrays to the database.
At this time, this option is not suggested. The sql is not as readable, and currently
this does not support the `:sql` update_strategy.
```ruby
t.binary "ancestry", limit: 3000, null: false
t.index "ancestry"
```
You may be able to alter the database to gain some readability:
```SQL
ALTER DATABASE dbname SET bytea_output to 'escape';
```
## MySQL Storage options
### ascii field collation
The currently suggested way to create a MySQL field is using `'utf8mb4_bin'` collation:
```ruby
t.string "ancestry", collation: 'utf8mb4_bin', null: false
t.index "ancestry"
```
### binary collation
Collation of `binary` acts much the same way as the `binary` column:
```ruby
t.string "ancestry", collate: 'binary', limit: 3000, null: false
t.index "ancestry"
```
### binary column
```ruby
t.binary "ancestry", limit: 3000, null: false
t.index "ancestry"
```
### ascii character set
MySQL supports per column character sets. Using a character set of `ascii` will
set this up.
```SQL
ALTER TABLE table
ADD COLUMN ancestry VARCHAR(2700) CHARACTER SET ascii;
```
# Ancestry Formats
You can choose from 2 ancestry formats:
- `:materialized_path` - legacy format (currently the default for backwards compatibility reasons)
- `:materialized_path2` - newer format. Use this if it is a new column
```
:materialized_path 1/2/3, root nodes ancestry=nil
descendants SQL: ancestry LIKE '1/2/3/%' OR ancestry = '1/2/3'
:materialized_path2 /1/2/3/, root nodes ancestry=/
descendants SQL: ancestry LIKE '/1/2/3/%'
```
If you are unsure, choose `:materialized_path2`. It allows a not NULL column,
faster descendant queries, has one less `OR` statement in the queries, and
the path can be formed easily in a database query for added benefits.
There is more discussion in [Internals](#internals) or [Migrating ancestry format](#migrate-ancestry-format)
For migrating from `materialized_path` to `materialized_path2` see [Ancestry Column](#ancestry-column)
## Migrating Ancestry Format
To migrate from `materialized_path` to `materialized_path2`:
```ruby
klass = YourModel
# set all child nodes
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}')")
# set all root nodes
klass.where(klass.arel_table[klass.ancestry_column].eq(nil)).update_all("#{klass.ancestry_column} = '#{klass.ancestry_root}'")
change_column_null klass.table_name, klass.ancestry_column, false
```
# Migrating from plugin that uses parent_id column
Most current tree plugins use a parent_id column (has_ancestry,
awesome_nested_set, better_nested_set, acts_as_nested_set). With Ancestry it is
easy to migrate from any of these plugins. To do so, use the
`build_ancestry_from_parent_ids!` method on your ancestry model.
It should be relatively simple to migrating from a plugin that uses a `parent_id`
column, (e.g.: `awesome_nested_set`, `better_nested_set`, `acts_as_nested_set`).
<details>
<summary>Details</summary>
When running the installation steps, also remove the old gem from your `Gemfile`,
and remove the old gem's macros from the model.
1. Add ancestry column to your table
* Create migration: **rails g migration [add_ancestry_to_](table)
ancestry:string**
* Add index to migration: **add_index [table], :ancestry** (UP) /
**remove_index [table], :ancestry** (DOWN)
* Migrate your database: **rake db:migrate**
Then populate the `ancestry` column from rails console:
```ruby
Model.build_ancestry_from_parent_ids!
# Model.rebuild_depth_cache!
Model.check_ancestry_integrity!
```
2. Remove old tree gem and add in Ancestry to Gemfile
* See 'Installation' for more info on installing and configuring gems
It is time to run your code. Most tree methods should work fine with ancestry
and hopefully your tests only require a few minor tweaks to get up and running.
Once you are happy with how your app is running, remove the old `parent_id` column:
3. Change your model
* Remove any macros required by old plugin/gem from
`[app/models/](model).rb`
* Add to `[app/models/](model).rb`: `has_ancestry`
```bash
$ rails g migration remove_parent_id_from_[table]
```
```ruby
class RemoveParentIdFromToTable < ActiveRecord::Migration[6.1]
def change
remove_column "table", "parent_id", type: :integer
end
end
```
4. Generate ancestry columns
* In rails console: **[model].build_ancestry_from_parent_ids!**
* Make sure it worked ok: **[model].check_ancestry_integrity!**
```bash
$ rake db:migrate
```
# Depth cache
5. Change your code
* Most tree calls will probably work fine with ancestry
* Others must be changed or proxied
* Check if all your data is intact and all tests pass
## Depth Cache Migration
To add depth_caching to an existing model:
6. Drop parent_id column:
* Create migration: `rails g migration [remove_parent_id_from_](table)`
* Add to migration: `remove_column [table], :parent_id`
* Migrate your database: `rake db:migrate`
</details>
## Add column
```ruby
class AddDepthCacheToTable < ActiveRecord::Migration[6.1]
def change
change_table(:table) do |t|
t.integer "ancestry_depth", default: 0
end
end
end
```
## Add ancestry to your model
```ruby
# app/models/[model.rb]
class [Model] < ActiveRecord::Base
has_ancestry cache_depth: true
end
```
## Update existing values
Add a custom script or run from rails console.
Some use migrations, but that can make the migration suite fragile. The command of interest is:
```ruby
Model.rebuild_depth_cache!
```
# Running Tests
@ -297,26 +533,6 @@ appraisal rake test
appraisal sqlite3-ar-50 rake test
```
# Internals
Ancestry stores a path from the root to the parent for every node.
This is a variation on the materialised path database pattern.
It allows Ancestry to fetch any relation (siblings,
descendants, etc.) in a single SQL query without the complicated algorithms
and incomprehensibility associated with left and right values. Additionally,
any inserts, deletes and updates only affect nodes within the affected node's
own subtree.
In the example above, the `ancestry` column is created as a `string`. This puts a
limitation on the depth of the tree of about 40 or 50 levels. To increase the
maximum depth of the tree, increase the size of the `string` or use `text` to
remove the limitation entirely. Changing it to a text will however decrease
performance because an index cannot be put on the column in that case.
The materialised path pattern requires Ancestry to use a 'like' condition in
order to fetch descendants. The wild character (`%`) is on the right of the
query, so indexes should be used.
# Contributing and license
Question? Bug report? Faulty/incomplete documentation? Feature request? Please

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require 'bundler/setup'
require 'bundler/gem_tasks'
require 'rake/testtask'

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true
lib = File.expand_path('../lib/', __FILE__)
$:.unshift lib unless $:.include?(lib)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'ancestry/version'
Gem::Specification.new do |s|
@ -22,6 +24,7 @@ EOF
"changelog_uri" => "https://github.com/stefankroes/ancestry/blob/master/CHANGELOG.md",
"source_code_uri" => "https://github.com/stefankroes/ancestry/",
"bug_tracker_uri" => "https://github.com/stefankroes/ancestry/issues",
"rubygems_mfa_required" => "true"
}
s.version = Ancestry::VERSION
@ -30,18 +33,20 @@ EOF
s.homepage = 'https://github.com/stefankroes/ancestry'
s.license = 'MIT'
s.files = Dir[
s.files = Dir[
"{lib}/**/*",
'CHANGELOG.md',
'MIT-LICENSE',
'README.md'
]
s.require_paths = ["lib"]
s.required_ruby_version = '>= 2.0.0'
s.add_runtime_dependency 'activerecord', '>= 4.2.0'
s.required_ruby_version = '>= 2.5'
s.add_runtime_dependency 'activerecord', '>= 5.2.6'
s.add_runtime_dependency 'logger'
s.add_development_dependency 'appraisal'
s.add_development_dependency 'minitest'
s.add_development_dependency 'rake', '~> 13.0'
s.add_development_dependency 'rake', '~> 13.0'
s.add_development_dependency 'simplecov'
s.add_development_dependency 'yard'
end

13
bin/console Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "bundler/setup"
require "active_support"
require "active_record"
require "ancestry"
# models for local testing
# Dir['./spec/support/**/*.rb'].sort.each { |f| require f }
require "irb"
IRB.start(__FILE__)

6
bin/setup Normal file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
psql -c 'create database ancestry_test;' || echo 'db exists'
mysql -e 'CREATE SCHEMA IF NOT EXISTS 'ancestry_test';'
#MAKE="make -j $(nproc)" bundle install --gemfile gemfiles/gemfile_61.gemfile

View File

@ -1,11 +0,0 @@
# This file was generated by Appraisal
source "https://rubygems.org"
gem "activerecord", "4.2.11.3"
gem "coveralls", require: false
gem "mysql2", "~> 0.4.0"
gem "pg", "0.18.4"
gem "sqlite3", "~> 1.3.13"
gemspec path: "../"

View File

@ -1,11 +0,0 @@
# This file was generated by Appraisal
source "https://rubygems.org"
gem "activerecord", "5.0.7.2"
gem "coveralls", require: false
gem "mysql2"
gem "pg"
gem "sqlite3", "~> 1.3.13"
gemspec path: "../"

View File

@ -1,11 +1,11 @@
# This file was generated by Appraisal
# frozen_string_literal: true
source "https://rubygems.org"
gem "activerecord", "5.2.4.3"
gem "coveralls", require: false
gem "activerecord", "~> 5.2.8"
gem "mysql2"
gem "pg"
gem "sqlite3"
gem "sqlite3", "~> 1.6.9"
gemspec path: "../"

View File

@ -1,11 +1,12 @@
# This file was generated by Appraisal
# frozen_string_literal: true
source "https://rubygems.org"
gem "activerecord", "6.0.3.2"
gem "coveralls", require: false
gem "activerecord", "~> 6.0.6"
gem "mysql2"
gem "pg"
gem "sqlite3"
gem "sqlite3", "~> 1.6.9"
gem "concurrent-ruby", "< 1.3.5"
gemspec path: "../"

View File

@ -0,0 +1,12 @@
# This file was generated by Appraisal
# frozen_string_literal: true
source "https://rubygems.org"
gem "activerecord", "~> 6.1.7"
gem "mysql2"
gem "pg"
gem "sqlite3", "~> 1.6.9"
gem "concurrent-ruby", "< 1.3.5"
gemspec path: "../"

View File

@ -0,0 +1,12 @@
# This file was generated by Appraisal
# frozen_string_literal: true
source "https://rubygems.org"
gem "activerecord", "~> 7.0.8"
gem "mysql2"
gem "pg"
gem "sqlite3", "< 2.0"
gem "concurrent-ruby", "< 1.3.5"
gemspec path: "../"

View File

@ -0,0 +1,12 @@
# This file was generated by Appraisal
# frozen_string_literal: true
source "https://rubygems.org"
gem "activerecord", "~> 7.1.3"
gem "mysql2"
gem "pg"
gem "sqlite3", "< 2.0"
gem "concurrent-ruby", "< 1.3.5"
gemspec path: "../"

View File

@ -1,11 +1,11 @@
# This file was generated by Appraisal
# frozen_string_literal: true
source "https://rubygems.org"
gem "activerecord", "5.1.7"
gem "coveralls", require: false
gem "activerecord", "~> 7.2.1"
gem "mysql2"
gem "pg"
gem "sqlite3", "~> 1.3.13"
gem "sqlite3", "< 2.0"
gemspec path: "../"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,16 +1,21 @@
# frozen_string_literal: true
require_relative 'ancestry/version'
require_relative 'ancestry/class_methods'
require_relative 'ancestry/instance_methods'
require_relative 'ancestry/exceptions'
require_relative 'ancestry/has_ancestry'
require_relative 'ancestry/materialized_path'
require_relative 'ancestry/materialized_path2'
require_relative 'ancestry/materialized_path_pg'
I18n.load_path += Dir[File.join(File.expand_path(File.dirname(__FILE__)),
'ancestry', 'locales', '*.{rb,yml}').to_s]
'ancestry', 'locales', '*.{rb,yml}').to_s]
module Ancestry
@@default_update_strategy = :ruby
@@default_ancestry_format = :materialized_path
@@default_primary_key_format = '[0-9]+'
# @!default_update_strategy
# @return [Symbol] the default strategy for updating ancestry
@ -26,7 +31,6 @@ module Ancestry
#
# Child records are updated in sql and callbacks will not get called.
# Associated records in memory will have the wrong ancestry value
def self.default_update_strategy
@@default_update_strategy
end
@ -34,4 +38,39 @@ module Ancestry
def self.default_update_strategy=(value)
@@default_update_strategy = value
end
# @!default_ancestry_format
# @return [Symbol] the default strategy for updating ancestry
#
# The value changes the default way that ancestry is stored in the database
#
# :materialized_path (default and legacy)
#
# Ancestry is of the form null (for no ancestors) and 1/2/ for children
#
# :materialized_path2 (preferred)
#
# Ancestry is of the form '/' (for no ancestors) and '/1/2/' for children
def self.default_ancestry_format
@@default_ancestry_format
end
def self.default_ancestry_format=(value)
@@default_ancestry_format = value
end
# @!default_primary_key_format
# @return [Symbol] the regular expression representing the primary key
#
# The value represents the way the id looks for validation
#
# '[0-9]+' (default) for integer ids
# '[-A-Fa-f0-9]{36}' for uuids (though you can find other regular expressions)
def self.default_primary_key_format
@@default_primary_key_format
end
def self.default_primary_key_format=(value)
@@default_primary_key_format = value
end
end

View File

@ -1,8 +1,10 @@
# frozen_string_literal: true
module Ancestry
module ClassMethods
# Fetch tree node if necessary
def to_node object
if object.is_a?(self.ancestry_base_class)
def to_node(object)
if object.is_a?(ancestry_base_class)
object
else
unscoped_where { |scope| scope.find(object.try(primary_key) || object) }
@ -10,44 +12,35 @@ module Ancestry
end
# Scope on relative depth options
def scope_depth depth_options, depth
depth_options.inject(self.ancestry_base_class) do |scope, option|
def scope_depth(depth_options, depth)
depth_options.inject(ancestry_base_class) do |scope, option|
scope_name, relative_depth = option
if [:before_depth, :to_depth, :at_depth, :from_depth, :after_depth].include? scope_name
scope.send scope_name, depth + relative_depth
else
raise Ancestry::AncestryException.new(I18n.t("ancestry.unknown_depth_option", {:scope_name => scope_name}))
raise Ancestry::AncestryException, I18n.t("ancestry.unknown_depth_option", scope_name: scope_name)
end
end
end
# Orphan strategy writer
def orphan_strategy= orphan_strategy
# Check value of orphan strategy, only rootify, adopt, restrict or destroy is allowed
if [:rootify, :adopt, :restrict, :destroy].include? orphan_strategy
class_variable_set :@@orphan_strategy, orphan_strategy
else
raise Ancestry::AncestryException.new(I18n.t("ancestry.invalid_orphan_strategy"))
end
end
# these methods arrange an entire subtree into nested hashes for easy navigation after database retrieval
# the arrange method also works on a scoped class
# the arrange method takes ActiveRecord find options
# To order your hashes pass the order to the arrange method instead of to the scope
# Get all nodes and sort them into an empty hash
def arrange options = {}
def arrange(options = {})
if (order = options.delete(:order))
arrange_nodes self.ancestry_base_class.order(order).where(options)
arrange_nodes(ancestry_base_class.order(order).where(options))
else
arrange_nodes self.ancestry_base_class.where(options)
arrange_nodes(ancestry_base_class.where(options))
end
end
# Arrange array of nodes into a nested hash of the form
# {node => children}, where children = {} if the node has no children
# arranges array of nodes to a hierarchical hash
#
# @param nodes [Array[Node]] nodes to be arranged
# @returns Hash{Node => {Node => {}, Node => {}}}
# If a node's parent is not included, the node will be included as if it is a top level node
def arrange_nodes(nodes)
node_ids = Set.new(nodes.map(&:id))
@ -60,10 +53,22 @@ module Ancestry
end
end
# Arrangement to nested array for serialization
# You can also supply your own serialization logic using blocks
# also allows you to pass the order just as you can pass it to the arrange method
def arrange_serializable options={}, nodes=nil, &block
# convert a hash of the form {node => children} to an array of nodes, child first
#
# @param arranged [Hash{Node => {Node => {}, Node => {}}}] arranged nodes
# @returns [Array[Node]] array of nodes with the parent before the children
def flatten_arranged_nodes(arranged, nodes = [])
arranged.each do |node, children|
nodes << node
flatten_arranged_nodes(children, nodes) unless children.empty?
end
nodes
end
# Arrangement to nested array for serialization
# You can also supply your own serialization logic using blocks
# also allows you to pass the order just as you can pass it to the arrange method
def arrange_serializable(options = {}, nodes = nil, &block)
nodes = arrange(options) if nodes.nil?
nodes.map do |parent, children|
if block_given?
@ -74,78 +79,79 @@ module Ancestry
end
end
def tree_view(column, data = nil)
data ||= arrange
data.each do |parent, children|
if parent.depth == 0
puts parent[column]
else
num = parent.depth - 1
indent = " " * num
puts " #{"|" if parent.depth > 1}#{indent}|_ #{parent[column]}"
end
tree_view(column, children) if children
end
end
# Pseudo-preordered array of nodes. Children will always follow parents,
def sort_by_ancestry(nodes, &block)
# This is deterministic unless the parents are missing *and* a sort block is specified
def sort_by_ancestry(nodes)
arranged = nodes if nodes.is_a?(Hash)
unless arranged
presorted_nodes = nodes.sort do |a, b|
a_cestry, b_cestry = a.ancestry || '0', b.ancestry || '0'
if block_given? && a_cestry == b_cestry
yield a, b
else
a_cestry <=> b_cestry
end
rank = (a.public_send(ancestry_column) || ' ') <=> (b.public_send(ancestry_column) || ' ')
rank = yield(a, b) if rank == 0 && block_given?
rank
end
arranged = arrange_nodes(presorted_nodes)
end
arranged.inject([]) do |sorted_nodes, pair|
node, children = pair
sorted_nodes << node
sorted_nodes += sort_by_ancestry(children, &block) unless children.blank?
sorted_nodes
end
flatten_arranged_nodes(arranged)
end
# Integrity checking
# compromised tree integrity is unlikely without explicitly setting cyclic parents or invalid ancestry and circumventing validation
# just in case, raise an AncestryIntegrityException if issues are detected
# specify :report => :list to return an array of exceptions or :report => :echo to echo any error messages
def check_ancestry_integrity! options = {}
def check_ancestry_integrity!(options = {})
parents = {}
exceptions = [] if options[:report] == :list
unscoped_where do |scope|
# For each node ...
scope.find_each do |node|
begin
# ... check validity of ancestry column
if !node.sane_ancestor_ids?
raise Ancestry::AncestryIntegrityException.new(I18n.t("ancestry.invalid_ancestry_column",
:node_id => node.id,
:ancestry_column => "#{node.read_attribute node.ancestry_column}"
))
# ... check validity of ancestry column
if !node.sane_ancestor_ids?
raise Ancestry::AncestryIntegrityException, I18n.t("ancestry.invalid_ancestry_column",
:node_id => node.id,
:ancestry_column => node.read_attribute(node.class.ancestry_column))
end
# ... check that all ancestors exist
node.ancestor_ids.each do |ancestor_id|
unless exists?(ancestor_id)
raise Ancestry::AncestryIntegrityException, I18n.t("ancestry.reference_nonexistent_node",
:node_id => node.id,
:ancestor_id => ancestor_id)
end
# ... check that all ancestors exist
node.ancestor_ids.each do |ancestor_id|
unless exists? ancestor_id
raise Ancestry::AncestryIntegrityException.new(I18n.t("ancestry.reference_nonexistent_node",
:node_id => node.id,
:ancestor_id => ancestor_id
))
end
end
# ... check that all node parents are consistent with values observed earlier
node.path_ids.zip([nil] + node.path_ids).each do |node_id, parent_id|
parents[node_id] = parent_id unless parents.has_key? node_id
unless parents[node_id] == parent_id
raise Ancestry::AncestryIntegrityException.new(I18n.t("ancestry.conflicting_parent_id",
:node_id => node_id,
:parent_id => parent_id || 'nil',
:expected => parents[node_id] || 'nil'
))
end
end
rescue Ancestry::AncestryIntegrityException => integrity_exception
case options[:report]
when :list then exceptions << integrity_exception
when :echo then puts integrity_exception
else raise integrity_exception
end
# ... check that all node parents are consistent with values observed earlier
node.path_ids.zip([nil] + node.path_ids).each do |node_id, parent_id|
parents[node_id] = parent_id unless parents.key?(node_id)
unless parents[node_id] == parent_id
raise Ancestry::AncestryIntegrityException, I18n.t("ancestry.conflicting_parent_id",
:node_id => node_id,
:parent_id => parent_id || 'nil',
:expected => parents[node_id] || 'nil')
end
end
rescue Ancestry::AncestryIntegrityException => e
case options[:report]
when :list then exceptions << e
when :echo then puts e
else raise e
end
end
end
exceptions if options[:report] == :list
@ -155,7 +161,7 @@ module Ancestry
def restore_ancestry_integrity!
parent_ids = {}
# Wrap the whole thing in a transaction ...
self.ancestry_base_class.transaction do
ancestry_base_class.transaction do
unscoped_where do |scope|
# For each node ...
scope.find_each do |node|
@ -192,7 +198,7 @@ module Ancestry
end
# Build ancestry from parent ids for migration purposes
def build_ancestry_from_parent_ids! column=:parent_id, parent_id = nil, ancestor_ids = []
def build_ancestry_from_parent_ids!(column = :parent_id, parent_id = nil, ancestor_ids = [])
unscoped_where do |scope|
scope.where(column => parent_id).find_each do |node|
node.without_ancestry_callbacks do
@ -205,9 +211,9 @@ module Ancestry
# Rebuild depth cache if it got corrupted or if depth caching was just turned on
def rebuild_depth_cache!
raise Ancestry::AncestryException.new(I18n.t("ancestry.cannot_rebuild_depth_cache")) unless respond_to? :depth_cache_column
raise(Ancestry::AncestryException, I18n.t("ancestry.cannot_rebuild_depth_cache")) unless respond_to?(:depth_cache_column)
self.ancestry_base_class.transaction do
ancestry_base_class.transaction do
unscoped_where do |scope|
scope.find_each do |node|
node.update_attribute depth_cache_column, node.depth
@ -216,32 +222,45 @@ module Ancestry
end
end
def unscoped_where
if ActiveRecord::VERSION::MAJOR < 4
self.ancestry_base_class.unscoped do
yield self.ancestry_base_class
end
# NOTE: this is temporarily kept separate from rebuild_depth_cache!
# this will become the implementation of rebuild_depth_cache!
def rebuild_depth_cache_sql!
update_all("#{depth_cache_column} = #{ancestry_depth_sql}")
end
def rebuild_counter_cache!
if %w(mysql mysql2).include?(connection.adapter_name.downcase)
connection.execute %{
UPDATE #{table_name} AS dest
LEFT JOIN (
SELECT #{table_name}.#{primary_key}, COUNT(*) AS child_count
FROM #{table_name}
JOIN #{table_name} children ON children.#{ancestry_column} = (#{child_ancestry_sql})
GROUP BY #{table_name}.#{primary_key}
) src USING(#{primary_key})
SET dest.#{counter_cache_column} = COALESCE(src.child_count, 0)
}
else
yield self.ancestry_base_class.unscope(:where)
update_all %{
#{counter_cache_column} = (
SELECT COUNT(*)
FROM #{table_name} children
WHERE children.#{ancestry_column} = (#{child_ancestry_sql})
)
}
end
end
def unscoped_where
yield ancestry_base_class.default_scoped.unscope(:where)
end
ANCESTRY_UNCAST_TYPES = [:string, :uuid, :text].freeze
if ActiveSupport::VERSION::STRING < "4.2"
def primary_key_is_an_integer?
if defined?(@primary_key_is_an_integer)
@primary_key_is_an_integer
else
@primary_key_is_an_integer = !ANCESTRY_UNCAST_TYPES.include?(columns_hash[primary_key.to_s].type)
end
end
else
def primary_key_is_an_integer?
if defined?(@primary_key_is_an_integer)
@primary_key_is_an_integer
else
@primary_key_is_an_integer = !ANCESTRY_UNCAST_TYPES.include?(type_for_attribute(primary_key).type)
end
def primary_key_is_an_integer?
if defined?(@primary_key_is_an_integer)
@primary_key_is_an_integer
else
@primary_key_is_an_integer = !ANCESTRY_UNCAST_TYPES.include?(type_for_attribute(primary_key).type)
end
end
end

View File

@ -1,7 +1,9 @@
# frozen_string_literal: true
module Ancestry
class AncestryException < RuntimeError
end
class AncestryIntegrityException < AncestryException
end
end
end

View File

@ -1,23 +1,40 @@
# frozen_string_literal: true
module Ancestry
module HasAncestry
def has_ancestry options = {}
def has_ancestry(options = {})
# Check options
raise Ancestry::AncestryException.new(I18n.t("ancestry.option_must_be_hash")) unless options.is_a? Hash
options.each do |key, value|
unless [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column, :touch, :counter_cache, :primary_key_format, :update_strategy].include? key
raise Ancestry::AncestryException.new(I18n.t("ancestry.unknown_option", {:key => key.inspect, :value => value.inspect}))
end
unless options.is_a? Hash
raise Ancestry::AncestryException, I18n.t("ancestry.option_must_be_hash")
end
extra_keys = options.keys - [:ancestry_column, :orphan_strategy, :cache_depth, :depth_cache_column, :touch, :counter_cache, :primary_key_format, :update_strategy, :ancestry_format]
if (key = extra_keys.first)
raise Ancestry::AncestryException, I18n.t("ancestry.unknown_option", key: key.inspect, value: options[key].inspect)
end
ancestry_format = options[:ancestry_format] || Ancestry.default_ancestry_format
if ![:materialized_path, :materialized_path2].include?(ancestry_format)
raise Ancestry::AncestryException, I18n.t("ancestry.unknown_format", value: ancestry_format)
end
orphan_strategy = options[:orphan_strategy] || :destroy
# Create ancestry column accessor and set to option or default
cattr_accessor :ancestry_column
self.ancestry_column = options[:ancestry_column] || :ancestry
class_variable_set('@@ancestry_column', options[:ancestry_column] || :ancestry)
cattr_reader :ancestry_column, instance_reader: false
primary_key_format = options[:primary_key_format].presence || Ancestry.default_primary_key_format
class_variable_set('@@ancestry_delimiter', '/')
cattr_reader :ancestry_delimiter, instance_reader: false
# Save self as base class (for STI)
cattr_accessor :ancestry_base_class
self.ancestry_base_class = self
class_variable_set('@@ancestry_base_class', self)
cattr_reader :ancestry_base_class, instance_reader: false
# Touch ancestors after updating
# days are limited. need to handle touch in pg case
cattr_accessor :touch_ancestors
self.touch_ancestors = options[:touch] || false
@ -26,31 +43,47 @@ module Ancestry
# Include dynamic class methods
extend Ancestry::ClassMethods
extend Ancestry::HasAncestry.ancestry_format_module(ancestry_format)
validates_format_of self.ancestry_column, :with => derive_ancestry_pattern(options[:primary_key_format]), :allow_nil => true
extend Ancestry::MaterializedPath
attribute ancestry_column, default: ancestry_root
validates ancestry_column, ancestry_validation_options(primary_key_format)
update_strategy = options[:update_strategy] || Ancestry.default_update_strategy
include Ancestry::MaterializedPathPg if update_strategy == :sql
# Create orphan strategy accessor and set to option or default (writer comes from DynamicClassMethods)
cattr_reader :orphan_strategy
self.orphan_strategy = options[:orphan_strategy] || :destroy
# Validate that the ancestor ids don't include own id
validate :ancestry_exclude_self
# Update descendants with new ancestry before save
before_save :update_descendants_with_new_ancestry
# Update descendants with new ancestry after update
after_update :update_descendants_with_new_ancestry, if: :ancestry_changed?
# Apply orphan strategy before destroy
before_destroy :apply_orphan_strategy
orphan_strategy_helper = "apply_orphan_strategy_#{orphan_strategy}"
if method_defined?(orphan_strategy_helper)
alias_method :apply_orphan_strategy, orphan_strategy_helper
before_destroy :apply_orphan_strategy
elsif orphan_strategy.to_s != "none"
raise Ancestry::AncestryException, I18n.t("ancestry.invalid_orphan_strategy")
end
# Create ancestry column accessor and set to option or default
if options[:cache_depth]
if options[:cache_depth] == :virtual
# NOTE: not setting self.depth_cache_column so the code does not try to update the column
depth_cache_sql = options[:depth_cache_column]&.to_s || 'ancestry_depth'
elsif options[:cache_depth]
# Create accessor for column name and set to option or default
self.cattr_accessor :depth_cache_column
self.depth_cache_column = options[:depth_cache_column] || :ancestry_depth
cattr_accessor :depth_cache_column
self.depth_cache_column =
if options[:cache_depth] == true
options[:depth_cache_column]&.to_s || 'ancestry_depth'
else
options[:cache_depth].to_s
end
if options[:depth_cache_column]
ActiveSupport::Deprecation.warn("has_ancestry :depth_cache_column is deprecated. Use :cache_depth instead.")
end
# Cache depth in depth cache column before save
before_validation :cache_depth
@ -58,62 +91,54 @@ module Ancestry
# Validate depth column
validates_numericality_of depth_cache_column, :greater_than_or_equal_to => 0, :only_integer => true, :allow_nil => false
depth_cache_sql = depth_cache_column
else
# this is not efficient, but it works
depth_cache_sql = ancestry_depth_sql
end
scope :before_depth, lambda { |depth| where("#{depth_cache_sql} < ?", depth) }
scope :to_depth, lambda { |depth| where("#{depth_cache_sql} <= ?", depth) }
scope :at_depth, lambda { |depth| where("#{depth_cache_sql} = ?", depth) }
scope :from_depth, lambda { |depth| where("#{depth_cache_sql} >= ?", depth) }
scope :after_depth, lambda { |depth| where("#{depth_cache_sql} > ?", depth) }
# Create counter cache column accessor and set to option or default
if options[:counter_cache]
cattr_accessor :counter_cache_column
if options[:counter_cache] == true
self.counter_cache_column = :children_count
else
self.counter_cache_column = options[:counter_cache]
end
self.counter_cache_column = options[:counter_cache] == true ? 'children_count' : options[:counter_cache].to_s
after_create :increase_parent_counter_cache, if: :has_parent?
after_destroy :decrease_parent_counter_cache, if: :has_parent?
after_update :update_parent_counter_cache
end
# Create named scopes for depth
{:before_depth => '<', :to_depth => '<=', :at_depth => '=', :from_depth => '>=', :after_depth => '>'}.each do |scope_name, operator|
scope scope_name, lambda { |depth|
raise Ancestry::AncestryException.new(I18n.t("ancestry.named_scope_depth_cache",
:scope_name => scope_name
)) unless options[:cache_depth]
where("#{depth_cache_column} #{operator} ?", depth)
}
end
after_touch :touch_ancestors_callback
after_destroy :touch_ancestors_callback
if ActiveRecord::VERSION::STRING >= '5.1.0'
if options[:touch]
after_touch :touch_ancestors_callback
after_destroy :touch_ancestors_callback
after_save :touch_ancestors_callback, if: :saved_changes?
else
after_save :touch_ancestors_callback, if: :changed?
end
end
def acts_as_tree(*args)
return super if defined?(super)
has_ancestry(*args)
end
private
def derive_ancestry_pattern(primary_key_format, delimiter = '/')
primary_key_format ||= '[0-9]+'
if primary_key_format.to_s.include?('\A')
primary_key_format
def self.ancestry_format_module(ancestry_format)
ancestry_format ||= Ancestry.default_ancestry_format
if ancestry_format == :materialized_path2
Ancestry::MaterializedPath2
else
/\A#{primary_key_format}(#{delimiter}#{primary_key_format})*\Z/
Ancestry::MaterializedPath
end
end
end
end
require 'active_support'
ActiveSupport.on_load :active_record do
send :extend, Ancestry::HasAncestry
extend Ancestry::HasAncestry
end

View File

@ -1,56 +1,71 @@
# frozen_string_literal: true
module Ancestry
module InstanceMethods
# Validate that the ancestors don't include itself
def ancestry_exclude_self
errors.add(:base, I18n.t("ancestry.exclude_self", {:class_name => self.class.name.humanize})) if ancestor_ids.include? self.id
errors.add(:base, I18n.t("ancestry.exclude_self", class_name: self.class.name.humanize)) if ancestor_ids.include?(id)
end
# Update descendants with new ancestry (before save)
# Update descendants with new ancestry (after update)
def update_descendants_with_new_ancestry
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestry?
# If enabled and the new ancestry is sane ...
# The only way the ancestry could be bad is via `update_attribute` with a bad value
if !ancestry_callbacks_disabled? && sane_ancestor_ids?
# ... for each descendant ...
unscoped_descendants.each do |descendant|
unscoped_descendants_before_last_save.each do |descendant|
# ... replace old ancestry with new ancestry
descendant.without_ancestry_callbacks do
new_ancestor_ids = path_ids + (descendant.ancestor_ids - path_ids_in_database)
new_ancestor_ids = path_ids + (descendant.ancestor_ids - path_ids_before_last_save)
descendant.update_attribute(:ancestor_ids, new_ancestor_ids)
end
end
end
end
# Apply orphan strategy (before destroy - no changes)
def apply_orphan_strategy
if !ancestry_callbacks_disabled? && !new_record?
case self.ancestry_base_class.orphan_strategy
when :rootify # make all children root if orphan strategy is rootify
unscoped_descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.update_attribute :ancestor_ids, descendant.ancestor_ids - path_ids
end
end
when :destroy # destroy all descendants if orphan strategy is destroy
unscoped_descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.destroy
end
end
when :adopt # make child elements of this node, child of its parent
descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.update_attribute :ancestor_ids, descendant.ancestor_ids.delete_if { |x| x == self.id }
end
end
when :restrict # throw an exception if it has children
raise Ancestry::AncestryException.new(I18n.t("ancestry.cannot_delete_descendants")) unless is_childless?
# make all children root if orphan strategy is rootify
def apply_orphan_strategy_rootify
return if ancestry_callbacks_disabled? || new_record?
unscoped_descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.update_attribute :ancestor_ids, descendant.ancestor_ids - path_ids
end
end
end
# destroy all descendants if orphan strategy is destroy
def apply_orphan_strategy_destroy
return if ancestry_callbacks_disabled? || new_record?
unscoped_descendants.ordered_by_ancestry.reverse_order.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.destroy
end
end
end
# make child elements of this node, child of its parent
def apply_orphan_strategy_adopt
return if ancestry_callbacks_disabled? || new_record?
descendants.each do |descendant|
descendant.without_ancestry_callbacks do
descendant.update_attribute :ancestor_ids, (descendant.ancestor_ids.delete_if { |x| x == id })
end
end
end
# throw an exception if it has children
def apply_orphan_strategy_restrict
return if ancestry_callbacks_disabled? || new_record?
raise(Ancestry::AncestryException, I18n.t("ancestry.cannot_delete_descendants")) unless is_childless?
end
# Touch each of this record's ancestors (after save)
def touch_ancestors_callback
if !ancestry_callbacks_disabled? && self.ancestry_base_class.touch_ancestors
if !ancestry_callbacks_disabled?
# Touch each of the old *and* new ancestors
unscoped_current_and_previous_ancestors.each do |ancestor|
ancestor.without_ancestry_callbacks do
@ -62,7 +77,7 @@ module Ancestry
# Counter Cache
def increase_parent_counter_cache
self.class.increment_counter _counter_cache_column, parent_id
self.class.ancestry_base_class.increment_counter counter_cache_column, parent_id
end
def decrease_parent_counter_cache
@ -74,67 +89,70 @@ module Ancestry
return if defined?(@_trigger_destroy_callback) && !@_trigger_destroy_callback
return if ancestry_callbacks_disabled?
self.class.decrement_counter _counter_cache_column, parent_id
self.class.ancestry_base_class.decrement_counter counter_cache_column, parent_id
end
def update_parent_counter_cache
changed =
if ActiveRecord::VERSION::STRING >= '5.1.0'
saved_change_to_attribute?(self.ancestry_base_class.ancestry_column)
else
ancestry_changed?
end
return unless saved_change_to_attribute?(self.class.ancestry_column)
return unless changed
if parent_id_was = parent_id_before_last_save
self.class.decrement_counter _counter_cache_column, parent_id_was
if (parent_id_was = parent_id_before_last_save)
self.class.ancestry_base_class.decrement_counter counter_cache_column, parent_id_was
end
parent_id && self.class.increment_counter(_counter_cache_column, parent_id)
end
def _counter_cache_column
self.ancestry_base_class.counter_cache_column.to_s
parent_id && increase_parent_counter_cache
end
# Ancestors
def ancestors?
def has_parent?
ancestor_ids.present?
end
alias :has_parent? :ancestors?
alias ancestors? has_parent?
def ancestry_changed?
column = self.ancestry_base_class.ancestry_column.to_s
if ActiveRecord::VERSION::STRING >= '5.1.0'
# These methods return nil if there are no changes.
# This was fixed in a refactoring in rails 6.0: https://github.com/rails/rails/pull/35933
!!(will_save_change_to_attribute?(column) || saved_change_to_attribute?(column))
else
changed.include?(column)
end
column = self.class.ancestry_column.to_s
# These methods return nil if there are no changes.
# This was fixed in a refactoring in rails 6.0: https://github.com/rails/rails/pull/35933
!!(will_save_change_to_attribute?(column) || saved_change_to_attribute?(column))
end
def sane_ancestor_ids?
valid? || errors[self.ancestry_base_class.ancestry_column].blank?
current_context, self.validation_context = validation_context, nil
errors.clear
attribute = self.class.ancestry_column
ancestry_value = send(attribute)
return true unless ancestry_value
self.class.validators_on(attribute).each do |validator|
validator.validate_each(self, attribute, ancestry_value)
end
ancestry_exclude_self
errors.none?
ensure
self.validation_context = current_context
end
def ancestors depth_options = {}
return self.ancestry_base_class.none unless ancestors?
self.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.ancestors_of(self)
def ancestors(depth_options = {})
return self.class.ancestry_base_class.none unless has_parent?
self.class.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.ancestors_of(self)
end
def path_ids
ancestor_ids + [id]
end
def path_ids_before_last_save
ancestor_ids_before_last_save + [id]
end
def path_ids_in_database
ancestor_ids_in_database + [id]
end
def path depth_options = {}
self.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.inpath_of(self)
def path(depth_options = {})
self.class.ancestry_base_class.scope_depth(depth_options, depth).ordered_by_ancestry.inpath_of(self)
end
def depth
@ -142,69 +160,77 @@ module Ancestry
end
def cache_depth
write_attribute self.ancestry_base_class.depth_cache_column, depth
write_attribute self.class.ancestry_base_class.depth_cache_column, depth
end
def ancestor_of?(node)
node.ancestor_ids.include?(self.id)
node.ancestor_ids.include?(id)
end
# Parent
# currently parent= does not work in after save callbacks
# assuming that parent hasn't changed
def parent= parent
def parent=(parent)
self.ancestor_ids = parent ? parent.path_ids : []
end
def parent_id= new_parent_id
def parent_id=(new_parent_id)
self.parent = new_parent_id.present? ? unscoped_find(new_parent_id) : nil
end
def parent_id
ancestor_ids.last if ancestors?
ancestor_ids.last if has_parent?
end
alias :parent_id? :ancestors?
alias parent_id? ancestors?
def parent
unscoped_find(parent_id) if ancestors?
if has_parent?
unscoped_where do |scope|
scope.find_by scope.primary_key => parent_id
end
end
end
def parent_of?(node)
self.id == node.parent_id
id == node.parent_id
end
# Root
def root_id
ancestors? ? ancestor_ids.first : id
has_parent? ? ancestor_ids.first : id
end
def root
ancestors? ? unscoped_find(root_id) : self
if has_parent?
unscoped_where { |scope| scope.find_by(scope.primary_key => root_id) } || self
else
self
end
end
def is_root?
!ancestors?
!has_parent?
end
alias :root? :is_root?
alias root? is_root?
def root_of?(node)
self.id == node.root_id
id == node.root_id
end
# Children
def children
self.ancestry_base_class.children_of(self)
self.class.ancestry_base_class.children_of(self)
end
def child_ids
children.pluck(self.ancestry_base_class.primary_key)
children.pluck(self.class.primary_key)
end
def has_children?
self.children.exists?
children.exists?
end
alias_method :children?, :has_children?
@ -214,22 +240,22 @@ module Ancestry
alias_method :childless?, :is_childless?
def child_of?(node)
self.parent_id == node.id
parent_id == node.id
end
# Siblings
def siblings
self.ancestry_base_class.siblings_of(self)
self.class.ancestry_base_class.siblings_of(self)
end
# NOTE: includes self
def sibling_ids
siblings.pluck(self.ancestry_base_class.primary_key)
siblings.pluck(self.class.primary_key)
end
def has_siblings?
self.siblings.count > 1
siblings.count > 1
end
alias_method :siblings?, :has_siblings?
@ -239,17 +265,17 @@ module Ancestry
alias_method :only_child?, :is_only_child?
def sibling_of?(node)
self.ancestor_ids == node.ancestor_ids
ancestor_ids == node.ancestor_ids
end
# Descendants
def descendants depth_options = {}
self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).descendants_of(self)
def descendants(depth_options = {})
self.class.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).descendants_of(self)
end
def descendant_ids depth_options = {}
descendants(depth_options).pluck(self.ancestry_base_class.primary_key)
def descendant_ids(depth_options = {})
descendants(depth_options).pluck(self.class.primary_key)
end
def descendant_of?(node)
@ -258,12 +284,12 @@ module Ancestry
# Indirects
def indirects depth_options = {}
self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).indirects_of(self)
def indirects(depth_options = {})
self.class.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).indirects_of(self)
end
def indirect_ids depth_options = {}
indirects(depth_options).pluck(self.ancestry_base_class.primary_key)
def indirect_ids(depth_options = {})
indirects(depth_options).pluck(self.class.primary_key)
end
def indirect_of?(node)
@ -272,12 +298,16 @@ module Ancestry
# Subtree
def subtree depth_options = {}
self.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).subtree_of(self)
def subtree(depth_options = {})
self.class.ancestry_base_class.ordered_by_ancestry.scope_depth(depth_options, depth).subtree_of(self)
end
def subtree_ids depth_options = {}
subtree(depth_options).pluck(self.ancestry_base_class.primary_key)
def subtree_ids(depth_options = {})
subtree(depth_options).pluck(self.class.primary_key)
end
def in_subtree_of?(node)
id == node.id || descendant_of?(node)
end
# Callback disabling
@ -285,6 +315,7 @@ module Ancestry
def without_ancestry_callbacks
@disable_ancestry_callbacks = true
yield
ensure
@disable_ancestry_callbacks = false
end
@ -292,30 +323,35 @@ module Ancestry
defined?(@disable_ancestry_callbacks) && @disable_ancestry_callbacks
end
private
private
def unscoped_descendants
unscoped_where do |scope|
scope.where self.ancestry_base_class.descendant_conditions(self)
scope.where(self.class.ancestry_base_class.descendant_conditions(self))
end
end
def unscoped_descendants_before_last_save
unscoped_where do |scope|
scope.where(self.class.ancestry_base_class.descendant_before_last_save_conditions(self))
end
end
# works with after save context (hence before_last_save)
def unscoped_current_and_previous_ancestors
unscoped_where do |scope|
scope.where id: (ancestor_ids + ancestor_ids_before_last_save).uniq
scope.where(scope.primary_key => (ancestor_ids + ancestor_ids_before_last_save).uniq)
end
end
def unscoped_find id
def unscoped_find(id)
unscoped_where do |scope|
scope.find id
scope.find(id)
end
end
def unscoped_where
self.ancestry_base_class.unscoped_where do |scope|
yield scope
end
def unscoped_where(&block)
self.class.ancestry_base_class.unscoped_where(&block)
end
end
end

View File

@ -9,7 +9,7 @@ en:
option_must_be_hash: "Options for has_ancestry must be in a hash."
unknown_option: "Unknown option for has_ancestry: %{key} => %{value}."
named_scope_depth_cache: "Named scope '%{scope_name}' is only available when depth caching is enabled."
unknown_format: "Unknown ancestry format: %{value}."
exclude_self: "%{class_name} cannot be a descendant of itself."
cannot_delete_descendants: "Cannot delete record because it has descendants."

View File

@ -1,9 +1,10 @@
module Ancestry
module MaterializedPath
BEFORE_LAST_SAVE_SUFFIX = ActiveRecord::VERSION::STRING >= '5.1.0' ? '_before_last_save'.freeze : '_was'.freeze
IN_DATABASE_SUFFIX = ActiveRecord::VERSION::STRING >= '5.1.0' ? '_in_database'.freeze : '_was'.freeze
ANCESTRY_DELIMITER='/'.freeze
# frozen_string_literal: true
module Ancestry
# store ancestry as grandparent_id/parent_id
# root a=nil,id=1 children=id,id/% == 1, 1/%
# 3: a=1/2,id=3 children=a/id,a/id/% == 1/2/3, 1/2/3/%
module MaterializedPath
def self.extended(base)
base.send(:include, InstanceMethods)
end
@ -13,7 +14,7 @@ module Ancestry
end
def roots
where(arel_table[ancestry_column].eq(nil))
where(arel_table[ancestry_column].eq(ancestry_root))
end
def ancestors_of(object)
@ -38,34 +39,32 @@ module Ancestry
def indirects_of(object)
t = arel_table
node = to_node(object)
# rails has case sensitive matching.
if ActiveRecord::VERSION::MAJOR >= 5
where(t[ancestry_column].matches("#{node.child_ancestry}/%", nil, true))
else
where(t[ancestry_column].matches("#{node.child_ancestry}/%"))
end
where(t[ancestry_column].matches("#{node.child_ancestry}#{ancestry_delimiter}%", nil, true))
end
def descendants_of(object)
where(descendant_conditions(object))
end
# deprecated
def descendant_conditions(object)
def descendants_by_ancestry(ancestry)
t = arel_table
t[ancestry_column].matches("#{ancestry}#{ancestry_delimiter}%", nil, true).or(t[ancestry_column].eq(ancestry))
end
def descendant_conditions(object)
node = to_node(object)
# rails has case sensitive matching.
if ActiveRecord::VERSION::MAJOR >= 5
t[ancestry_column].matches("#{node.child_ancestry}/%", nil, true).or(t[ancestry_column].eq(node.child_ancestry))
else
t[ancestry_column].matches("#{node.child_ancestry}/%").or(t[ancestry_column].eq(node.child_ancestry))
end
descendants_by_ancestry(node.child_ancestry)
end
def descendant_before_last_save_conditions(object)
node = to_node(object)
descendants_by_ancestry(node.child_ancestry_before_last_save)
end
def subtree_of(object)
t = arel_table
node = to_node(object)
where(descendant_conditions(node).or(t[primary_key].eq(node.id)))
descendants_of(node).or(where(t[primary_key].eq(node.id)))
end
def siblings_of(object)
@ -77,8 +76,8 @@ module Ancestry
def ordered_by_ancestry(order = nil)
if %w(mysql mysql2 sqlite sqlite3).include?(connection.adapter_name.downcase)
reorder(arel_table[ancestry_column], order)
elsif %w(postgresql).include?(connection.adapter_name.downcase) && ActiveRecord::VERSION::STRING >= "6.1"
reorder(Arel::Nodes::Ascending.new(arel_table[ancestry_column]).nulls_first)
elsif %w(postgresql oracleenhanced).include?(connection.adapter_name.downcase) && ActiveRecord::VERSION::STRING >= "6.1"
reorder(Arel::Nodes::Ascending.new(arel_table[ancestry_column]).nulls_first, order)
else
reorder(
Arel::Nodes::Ascending.new(Arel::Nodes::NamedFunction.new('COALESCE', [arel_table[ancestry_column], Arel.sql("''")])),
@ -91,65 +90,129 @@ module Ancestry
ordered_by_ancestry(order)
end
module InstanceMethods
def ancestry_root
nil
end
# Validates the ancestry, but can also be applied if validation is bypassed to determine if children should be affected
def sane_ancestry?
ancestry_value = read_attribute(self.ancestry_base_class.ancestry_column)
(ancestry_value.nil? || !ancestor_ids.include?(self.id)) && valid?
def child_ancestry_sql
%{
CASE WHEN #{table_name}.#{ancestry_column} IS NULL THEN #{concat("#{table_name}.#{primary_key}")}
ELSE #{concat("#{table_name}.#{ancestry_column}", "'#{ancestry_delimiter}'", "#{table_name}.#{primary_key}")}
END
}
end
def ancestry_depth_sql
@ancestry_depth_sql ||= MaterializedPath.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
end
def generate_ancestry(ancestor_ids)
if ancestor_ids.present? && ancestor_ids.any?
ancestor_ids.join(ancestry_delimiter)
else
ancestry_root
end
end
def parse_ancestry_column(obj)
return [] if obj.nil? || obj == ancestry_root
obj_ids = obj.split(ancestry_delimiter).delete_if(&:blank?)
primary_key_is_an_integer? ? obj_ids.map!(&:to_i) : obj_ids
end
def ancestry_depth_change(old_value, new_value)
parse_ancestry_column(new_value).size - parse_ancestry_column(old_value).size
end
def concat(*args)
if %w(sqlite sqlite3).include?(connection.adapter_name.downcase)
args.join('||')
else
%{CONCAT(#{args.join(', ')})}
end
end
def self.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
tmp = %{(LENGTH(#{table_name}.#{ancestry_column}) - LENGTH(REPLACE(#{table_name}.#{ancestry_column},'#{ancestry_delimiter}','')))}
tmp += "/#{ancestry_delimiter.size}" if ancestry_delimiter.size > 1
"(CASE WHEN #{table_name}.#{ancestry_column} IS NULL THEN 0 ELSE 1 + #{tmp} END)"
end
private
def ancestry_validation_options(ancestry_primary_key_format)
{
format: {with: ancestry_format_regexp(ancestry_primary_key_format)},
allow_nil: ancestry_nil_allowed?
}
end
def ancestry_nil_allowed?
true
end
def ancestry_format_regexp(primary_key_format)
/\A#{primary_key_format}(#{Regexp.escape(ancestry_delimiter)}#{primary_key_format})*\z/.freeze
end
module InstanceMethods
# optimization - better to go directly to column and avoid parsing
def ancestors?
read_attribute(self.ancestry_base_class.ancestry_column).present?
read_attribute(self.class.ancestry_column) != self.class.ancestry_root
end
alias :has_parent? :ancestors?
alias has_parent? ancestors?
def ancestor_ids=(value)
col = self.ancestry_base_class.ancestry_column
value.present? ? write_attribute(col, value.join(ANCESTRY_DELIMITER)) : write_attribute(col, nil)
write_attribute(self.class.ancestry_column, self.class.generate_ancestry(value))
end
def ancestor_ids
parse_ancestry_column(read_attribute(self.ancestry_base_class.ancestry_column))
self.class.parse_ancestry_column(read_attribute(self.class.ancestry_column))
end
def ancestor_ids_in_database
parse_ancestry_column(send("#{self.ancestry_base_class.ancestry_column}#{IN_DATABASE_SUFFIX}"))
self.class.parse_ancestry_column(attribute_in_database(self.class.ancestry_column))
end
def ancestor_ids_before_last_save
parse_ancestry_column(send("#{self.ancestry_base_class.ancestry_column}#{BEFORE_LAST_SAVE_SUFFIX}"))
self.class.parse_ancestry_column(attribute_before_last_save(self.class.ancestry_column))
end
def parent_id_in_database
self.class.parse_ancestry_column(attribute_in_database(self.class.ancestry_column)).last
end
def parent_id_before_last_save
ancestry_was = send("#{self.ancestry_base_class.ancestry_column}#{BEFORE_LAST_SAVE_SUFFIX}")
return unless ancestry_was.present?
parse_ancestry_column(ancestry_was).last
self.class.parse_ancestry_column(attribute_before_last_save(self.class.ancestry_column)).last
end
# optimization - better to go directly to column and avoid parsing
def sibling_of?(node)
self.read_attribute(self.ancestry_base_class.ancestry_column) == node.read_attribute(self.ancestry_base_class.ancestry_column)
read_attribute(self.class.ancestry_column) == node.read_attribute(node.class.ancestry_column)
end
# private (public so class methods can find it)
# The ancestry value for this record's children (before save)
# This is technically child_ancestry_was
# The ancestry value for this record's children
# This can also be thought of as the ancestry value for the path
# If this is a new record, it has no id, and it is not valid.
# NOTE: This could have been called child_ancestry_in_database
# the child records were created from the version in the database
def child_ancestry
# New records cannot have children
raise Ancestry::AncestryException.new(I18n.t("ancestry.no_child_for_new_record")) if new_record?
path_was = self.send("#{self.ancestry_base_class.ancestry_column}#{IN_DATABASE_SUFFIX}")
path_was.blank? ? id.to_s : "#{path_was}/#{id}"
raise(Ancestry::AncestryException, I18n.t("ancestry.no_child_for_new_record")) if new_record?
[attribute_in_database(self.class.ancestry_column), id].compact.join(self.class.ancestry_delimiter)
end
private
# The ancestry value for this record's old children
# Currently used in an after_update via unscoped_descendants_before_last_save
# to find the old children and bring them along (or to )
# This is not valid in a new record's after_save.
def child_ancestry_before_last_save
if new_record? || (respond_to?(:previously_new_record?) && previously_new_record?)
raise Ancestry::AncestryException, I18n.t("ancestry.no_child_for_new_record")
end
def parse_ancestry_column obj
return [] unless obj
obj_ids = obj.split(ANCESTRY_DELIMITER)
self.class.primary_key_is_an_integer? ? obj_ids.map!(&:to_i) : obj_ids
[attribute_before_last_save(self.class.ancestry_column), id].compact.join(self.class.ancestry_delimiter)
end
end
end

View File

@ -0,0 +1,84 @@
# frozen_string_literal: true
module Ancestry
# store ancestry as /grandparent_id/parent_id/
# root: a=/,id=1 children=#{a}#{id}/% == /1/%
# 3: a=/1/2/,id=3 children=#{a}#{id}/% == /1/2/3/%
module MaterializedPath2
include MaterializedPath
def self.extended(base)
base.send(:include, MaterializedPath::InstanceMethods)
base.send(:include, InstanceMethods)
end
def indirects_of(object)
t = arel_table
node = to_node(object)
where(t[ancestry_column].matches("#{node.child_ancestry}%#{ancestry_delimiter}%", nil, true))
end
def ordered_by_ancestry(order = nil)
reorder(Arel::Nodes::Ascending.new(arel_table[ancestry_column]), order)
end
def descendants_by_ancestry(ancestry)
arel_table[ancestry_column].matches("#{ancestry}%", nil, true)
end
def ancestry_root
ancestry_delimiter
end
def child_ancestry_sql
concat("#{table_name}.#{ancestry_column}", "#{table_name}.#{primary_key}", "'#{ancestry_delimiter}'")
end
def ancestry_depth_sql
@ancestry_depth_sql ||= MaterializedPath2.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
end
def generate_ancestry(ancestor_ids)
if ancestor_ids.present? && ancestor_ids.any?
"#{ancestry_delimiter}#{ancestor_ids.join(ancestry_delimiter)}#{ancestry_delimiter}"
else
ancestry_root
end
end
# module method
def self.construct_depth_sql(table_name, ancestry_column, ancestry_delimiter)
tmp = %{(LENGTH(#{table_name}.#{ancestry_column}) - LENGTH(REPLACE(#{table_name}.#{ancestry_column},'#{ancestry_delimiter}','')))}
tmp += "/#{ancestry_delimiter.size}" if ancestry_delimiter.size > 1
"(#{tmp} -1)"
end
private
def ancestry_nil_allowed?
false
end
def ancestry_format_regexp(primary_key_format)
/\A#{Regexp.escape(ancestry_delimiter)}(#{primary_key_format}#{Regexp.escape(ancestry_delimiter)})*\z/.freeze
end
module InstanceMethods
# Please see notes for MaterializedPath#child_ancestry
def child_ancestry
raise(Ancestry::AncestryException, I18n.t("ancestry.no_child_for_new_record")) if new_record?
"#{attribute_in_database(self.class.ancestry_column)}#{id}#{self.class.ancestry_delimiter}"
end
# Please see notes for MaterializedPath#child_ancestry_before_last_save
def child_ancestry_before_last_save
if new_record? || (respond_to?(:previously_new_record?) && previously_new_record?)
raise(Ancestry::AncestryException, I18n.t("ancestry.no_child_for_new_record"))
end
"#{attribute_before_last_save(self.class.ancestry_column)}#{id}#{self.class.ancestry_delimiter}"
end
end
end
end

View File

@ -1,23 +1,38 @@
# frozen_string_literal: true
module Ancestry
module MaterializedPathPg
# Update descendants with new ancestry (before save)
def update_descendants_with_new_ancestry
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
if !ancestry_callbacks_disabled? && !new_record? && ancestry_changed? && sane_ancestry?
ancestry_column = ancestry_base_class.ancestry_column
old_ancestry = path_ids_in_database.join(Ancestry::MaterializedPath::ANCESTRY_DELIMITER)
new_ancestry = path_ids.join(Ancestry::MaterializedPath::ANCESTRY_DELIMITER)
update_clause = [
"#{ancestry_column} = regexp_replace(#{ancestry_column}, '^#{old_ancestry}', '#{new_ancestry}')"
]
# Update descendants with new ancestry (after update)
def update_descendants_with_new_ancestry
# If enabled and node is existing and ancestry was updated and the new ancestry is sane ...
# The only way the ancestry could be bad is via `update_attribute` with a bad value
if !ancestry_callbacks_disabled? && sane_ancestor_ids?
old_ancestry = self.class.generate_ancestry(path_ids_before_last_save)
new_ancestry = self.class.generate_ancestry(path_ids)
update_clause = {
self.class.ancestry_column => Arel.sql("regexp_replace(#{self.class.ancestry_column}, '^#{Regexp.escape(old_ancestry)}', '#{new_ancestry}')")
}
if ancestry_base_class.respond_to?(:depth_cache_column) && respond_to?(ancestry_base_class.depth_cache_column)
depth_cache_column = ancestry_base_class.depth_cache_column.to_s
update_clause << "#{depth_cache_column} = length(regexp_replace(regexp_replace(ancestry, '^#{old_ancestry}', '#{new_ancestry}'), '\\d', '', 'g')) + 1"
end
current_time = current_time_from_proper_timezone
timestamp_attributes_for_update_in_model.each do |column|
update_clause[column] = current_time
end
unscoped_descendants.update_all update_clause.join(', ')
update_descendants_hook(update_clause, old_ancestry, new_ancestry)
unscoped_descendants_before_last_save.update_all update_clause
end
end
def update_descendants_hook(update_clause, old_ancestry, new_ancestry)
if self.class.respond_to?(:depth_cache_column)
depth_cache_column = self.class.depth_cache_column
depth_change = self.class.ancestry_depth_change(old_ancestry, new_ancestry)
if depth_change != 0
update_clause[depth_cache_column] = Arel.sql("#{depth_cache_column} + #{depth_change}")
end
end
update_clause
end
end
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
module Ancestry
VERSION = "3.2.1"
VERSION = '5.0.0'
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_relative '../environment'
class ArrangementTest < ActiveSupport::TestCase
@ -6,7 +8,7 @@ class ArrangementTest < ActiveSupport::TestCase
end
def middle_node(model)
root_node(model).children.sort_by(&:id).first
root_node(model).children.min_by(&:id)
end
def leaf_node(model)
@ -23,7 +25,7 @@ class ArrangementTest < ActiveSupport::TestCase
assert_equal size_at_depth[1], children.size
assert_equal node.children.sort_by(&:id), children.keys.sort_by(&:id)
assert_tree(children, size_at_depth[1..-1])
assert_tree(children, size_at_depth[1..])
end
end
@ -39,123 +41,137 @@ class ArrangementTest < ActiveSupport::TestCase
arranged_nodes.each do |node, children|
assert_equal expected_ids[0], node.id
assert_tree_path(children, expected_ids[1..-1])
assert_tree_path(children, expected_ids[1..])
end
end
def test_arrangement
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, _roots|
assert_tree model.arrange, [3, 3, 3, 0]
end
end
def test_subtree_arrange_root_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
assert_tree root_node(model).subtree.arrange, [1, 2, 2, 0]
end
end
def test_subtree_arrange_middle_node
AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, _roots|
assert_tree middle_node(model).subtree.arrange, [1, 2, 2, 0]
end
end
def test_subtree_arrange_leaf_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
assert_tree leaf_node(model).subtree.arrange, [1, 0]
end
end
def test_descendants_arrange_root_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
assert_tree root_node(model).descendants.arrange, [2, 2, 0]
end
end
def test_descendants_arrange_middle_node
AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, _roots|
assert_tree middle_node(model).descendants.arrange, [2, 2, 0]
end
end
def test_descendants_arrange_leaf_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
assert_tree leaf_node(model).descendants.arrange, [0]
end
end
def test_path_arrange_root_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
test_node = root_node(model)
assert_tree_path test_node.path.arrange, test_node.path_ids
end
end
def test_path_arrange_middle_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
test_node = middle_node(model)
assert_tree_path test_node.path.arrange, test_node.path_ids
end
end
def test_path_arrange_leaf_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
test_node = leaf_node(model)
assert_tree_path test_node.path.arrange, test_node.path_ids
end
end
def test_ancestors_arrange_root_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
test_node = root_node(model)
assert_tree_path test_node.ancestors.arrange, test_node.ancestor_ids
end
end
def test_ancestors_arrange_middle_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
test_node = middle_node(model)
assert_tree_path test_node.ancestors.arrange, test_node.ancestor_ids
end
end
def test_ancestors_arrange_leaf_node
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 2 do |model, _roots|
test_node = leaf_node(model)
assert_tree_path test_node.ancestors.arrange, test_node.ancestor_ids
end
end
def test_arrange_serializable
AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, roots|
result = [{"ancestry"=>nil,
"id"=>4,
"children"=>
[{"ancestry"=>"4", "id"=>6, "children"=>[]},
{"ancestry"=>"4", "id"=>5, "children"=>[]}]},
{"ancestry"=>nil,
"id"=>1,
"children"=>
[{"ancestry"=>"1", "id"=>3, "children"=>[]},
{"ancestry"=>"1", "id"=>2, "children"=>[]}]}]
AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, _roots|
col = model.ancestry_column
# materialized path 2 has a slash at the beginning and end
fmt =
if AncestryTestDatabase.materialized_path2?
->(a) { a ? "/#{a}/" : "/" }
else
->(a) { a }
end
result = [
{
col => fmt[nil], "id" => 4, "children" => [
{col => fmt["4"], "id" => 6, "children" => []},
{col => fmt["4"], "id" => 5, "children" => []}
]
}, {
col => fmt[nil], "id" => 1, "children" => [
{col => fmt["1"], "id" => 3, "children" => []},
{col => fmt["1"], "id" => 2, "children" => []}
]
}
]
assert_equal model.arrange_serializable(order: "id desc"), result
end
end
def test_arrange_serializable_with_block
AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, roots|
expected_result = [{
"id"=>4,
"children"=>
[{"id"=>6},
{"id"=>5}]},
{
"id"=>1,
"children"=>
[{"id"=>3},
{"id"=>2}]}]
AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, _roots|
expected_result = [
{
"id" => 4, "children" => [
{"id" => 6},
{"id" => 5}
]
}, {
"id" => 1, "children" => [
{"id" => 3},
{"id" => 2}
]
}
]
result = model.arrange_serializable(order: "id desc") do |parent, children|
out = {}
out["id"] = parent.id
@ -167,7 +183,7 @@ class ArrangementTest < ActiveSupport::TestCase
end
def test_arrange_order_option
AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, roots|
AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, _roots|
descending_nodes_lvl0 = model.arrange :order => 'id desc'
ascending_nodes_lvl0 = model.arrange :order => 'id asc'

View File

@ -1,7 +1,11 @@
# frozen_string_literal: true
require_relative '../environment'
class BuildAncestryTest < ActiveSupport::TestCase
def test_build_ancestry_from_parent_ids
ancestry_column = AncestryTestDatabase.ancestry_column
AncestryTestDatabase.with_model :skip_ancestry => true, :extra_columns => {:parent_id => :integer} do |model|
[model.create!].each do |parent1|
(Array.new(5) { model.create! :parent_id => parent1.id }).each do |parent2|
@ -14,7 +18,7 @@ class BuildAncestryTest < ActiveSupport::TestCase
# Assert all nodes where created
assert_equal (0..3).map { |n| 5 ** n }.sum, model.count
model.has_ancestry
model.has_ancestry ancestry_column: ancestry_column
model.build_ancestry_from_parent_ids!
# Assert ancestry integrity
@ -43,6 +47,8 @@ class BuildAncestryTest < ActiveSupport::TestCase
end
def test_build_ancestry_from_other_ids
ancestry_column = AncestryTestDatabase.ancestry_column
AncestryTestDatabase.with_model :skip_ancestry => true, :extra_columns => {:misc_id => :integer} do |model|
[model.create!].each do |parent1|
(Array.new(5) { model.create! :misc_id => parent1.id }).each do |parent2|
@ -55,7 +61,7 @@ class BuildAncestryTest < ActiveSupport::TestCase
# Assert all nodes where created
assert_equal (0..3).map { |n| 5 ** n }.sum, model.count
model.has_ancestry
model.has_ancestry ancestry_column: ancestry_column
model.build_ancestry_from_parent_ids! :misc_id
# Assert ancestry integrity

View File

@ -1,16 +1,18 @@
# frozen_string_literal: true
require_relative '../environment'
class CounterCacheTest < ActiveSupport::TestCase
def test_counter_cache_when_creating
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |model, roots|
roots.each do |lvl0_node, lvl0_children|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |_model, roots|
roots.each do |lvl0_node, _lvl0_children|
assert_equal 2, lvl0_node.reload.children_count
end
end
end
def test_counter_cache_when_destroying
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |model, roots|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |_model, roots|
parent = roots.first.first
child = parent.children.first
assert_difference 'parent.reload.children_count', -1 do
@ -20,22 +22,20 @@ class CounterCacheTest < ActiveSupport::TestCase
end
def test_counter_cache_when_reduplicate_destroying
return unless ActiveRecord::VERSION::STRING >= '5.1.0'
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |model, roots|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |_model, roots|
parent = roots.first.first
child = parent.children.first
_child = child.class.find(child.id)
child2 = child.class.find(child.id)
assert_difference 'parent.reload.children_count', -1 do
child.destroy
_child.destroy
child2.destroy
end
end
end
def test_counter_cache_when_updating_parent
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |model, roots|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |_model, roots|
parent1 = roots.first.first
parent2 = roots.last.first
child = parent1.children.first
@ -49,7 +49,7 @@ class CounterCacheTest < ActiveSupport::TestCase
end
def test_counter_cache_when_updating_parent_and_previous_is_nil
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |model, roots|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |_model, roots|
child = roots.first.first
parent = roots.last.first
@ -60,7 +60,7 @@ class CounterCacheTest < ActiveSupport::TestCase
end
def test_counter_cache_when_updating_parent_and_current_is_nil
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |model, roots|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true do |_model, roots|
parent = roots.first.first
child = parent.children.first
@ -71,15 +71,15 @@ class CounterCacheTest < ActiveSupport::TestCase
end
def test_custom_counter_cache_column
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => :nodes_count do |model, roots|
roots.each do |lvl0_node, lvl0_children|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => :nodes_count do |_model, roots|
roots.each do |lvl0_node, _lvl0_children|
assert_equal 2, lvl0_node.reload.nodes_count
end
end
end
def test_counter_cache_when_updating_record
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true, :extra_columns => {:name => :string} do |model, roots|
AncestryTestDatabase.with_model :depth => 2, :width => 2, :counter_cache => true, :extra_columns => {:name => :string} do |_model, roots|
parent = roots.first.first
child = parent.children.first
@ -88,4 +88,37 @@ class CounterCacheTest < ActiveSupport::TestCase
end
end
end
def test_setting_counter_cache
AncestryTestDatabase.with_model :depth => 3, :width => 2, :counter_cache => true do |model, roots|
# ensure they are successfully built
roots.each do |lvl0_node, lvl0_children|
assert_equal 2, lvl0_node.reload.children_count
lvl0_children.each do |lvl1_node, lvl1_children|
assert_equal 2, lvl1_node.reload.children_count
lvl1_children.each do |lvl2_node, _lvl2_children|
assert_equal 0, lvl2_node.reload.children_count
end
end
end
model.update_all(model.counter_cache_column => 0)
# ensure they are successfully broken
roots.each do |lvl0_node, _lvl0_children|
assert_equal 0, lvl0_node.reload.children_count
end
model.rebuild_counter_cache!
# ensure they are successfully built
roots.each do |lvl0_node, lvl0_children|
assert_equal 2, lvl0_node.reload.children_count
lvl0_children.each do |lvl1_node, lvl1_children|
assert_equal 2, lvl1_node.reload.children_count
lvl1_children.each do |lvl2_node, _lvl2_children|
assert_equal 0, lvl2_node.reload.children_count
end
end
end
end
end
end

View File

@ -1,13 +1,18 @@
# frozen_string_literal: true
require_relative '../environment'
class DbTest < ActiveSupport::TestCase
def test_does_not_load_database
c = Class.new(ActiveRecord::Base) do
self.table_name = "table"
def self.connection
raise "Oh No - tried to connect to database"
end
end
c.send(:has_ancestry)
assert true, "this should not connect to the database"
end
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_relative '../environment'
class DefaultScopesTest < ActiveSupport::TestCase
@ -5,7 +7,7 @@ class DefaultScopesTest < ActiveSupport::TestCase
AncestryTestDatabase.with_model(
:width => 3, :depth => 3, :extra_columns => {:deleted_at => :datetime},
:default_scope_params => {:deleted_at => nil}
) do |model, roots|
) do |model, _roots|
roots = model.roots.to_a
grandparent = roots[0]
new_grandparent = roots[1]
@ -27,7 +29,7 @@ class DefaultScopesTest < ActiveSupport::TestCase
:width => 1, :depth => 2, :extra_columns => {:deleted_at => :datetime},
:default_scope_params => {:deleted_at => nil},
:orphan_strategy => :destroy
) do |model, roots|
) do |model, _roots|
parent = model.roots.first
child = parent.children.first
@ -44,7 +46,7 @@ class DefaultScopesTest < ActiveSupport::TestCase
:width => 1, :depth => 2, :extra_columns => {:deleted_at => :datetime},
:default_scope_params => {:deleted_at => nil},
:orphan_strategy => :rootify
) do |model, roots|
) do |model, _roots|
parent = model.roots.first
child = parent.children.first

View File

@ -1,13 +1,15 @@
# frozen_string_literal: true
require_relative '../environment'
class DepthCachingTest < ActiveSupport::TestCase
def test_depth_caching
AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => true, :depth_cache_column => :depth_cache do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => :depth_cache do |_model, roots|
roots.each do |lvl0_node, lvl0_children|
assert_equal 0, lvl0_node.depth_cache
lvl0_children.each do |lvl1_node, lvl1_children|
assert_equal 1, lvl1_node.depth_cache
lvl1_children.each do |lvl2_node, lvl2_children|
lvl1_children.each do |lvl2_node, _lvl2_children|
assert_equal 2, lvl2_node.depth_cache
end
end
@ -16,7 +18,7 @@ class DepthCachingTest < ActiveSupport::TestCase
end
def test_depth_caching_after_subtree_movement
AncestryTestDatabase.with_model :depth => 6, :width => 1, :cache_depth => true, :depth_cache_column => :depth_cache do |model, roots|
AncestryTestDatabase.with_model :depth => 6, :width => 1, :cache_depth => :depth_cache do |model, _roots|
node = model.at_depth(3).first
node.update(:parent => model.roots.first)
assert_equal(1, node.depth_cache)
@ -27,7 +29,7 @@ class DepthCachingTest < ActiveSupport::TestCase
end
def test_depth_scopes
AncestryTestDatabase.with_model :depth => 4, :width => 2, :cache_depth => true do |model, roots|
AncestryTestDatabase.with_model :depth => 4, :width => 2, :cache_depth => true do |model, _roots|
model.before_depth(2).all? { |node| assert node.depth < 2 }
model.to_depth(2).all? { |node| assert node.depth <= 2 }
model.at_depth(2).all? { |node| assert node.depth == 2 }
@ -36,29 +38,19 @@ class DepthCachingTest < ActiveSupport::TestCase
end
end
def test_depth_scopes_unavailable
AncestryTestDatabase.with_model do |model|
assert_raise Ancestry::AncestryException do
model.before_depth(1)
end
assert_raise Ancestry::AncestryException do
model.to_depth(1)
end
assert_raise Ancestry::AncestryException do
model.at_depth(1)
end
assert_raise Ancestry::AncestryException do
model.from_depth(1)
end
assert_raise Ancestry::AncestryException do
model.after_depth(1)
end
def test_depth_scopes_without_depth_cache
AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, _roots|
model.before_depth(2).all? { |node| assert node.depth < 2 }
model.to_depth(2).all? { |node| assert node.depth <= 2 }
model.at_depth(2).all? { |node| assert node.depth == 2 }
model.from_depth(2).all? { |node| assert node.depth >= 2 }
model.after_depth(2).all? { |node| assert node.depth > 2 }
end
end
def test_rebuild_depth_cache
AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => true, :depth_cache_column => :depth_cache do |model, roots|
model.connection.execute("update test_nodes set depth_cache = null;")
AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => :depth_cache do |model, _roots|
model.update_all(:depth_cache => nil)
# Assert cache was emptied correctly
model.all.each do |test_node|
@ -75,6 +67,25 @@ class DepthCachingTest < ActiveSupport::TestCase
end
end
def test_rebuild_depth_cache_with_sql
AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => :depth_cache do |model, _roots|
model.update_all(:depth_cache => nil)
# Assert cache was emptied correctly
model.all.each do |test_node|
assert_nil test_node.depth_cache
end
# Rebuild cache
model.rebuild_depth_cache_sql!
# Assert cache was rebuild correctly
model.all.each do |test_node|
assert_equal test_node.depth, test_node.depth_cache
end
end
end
def test_exception_when_rebuilding_depth_cache_for_model_without_depth_caching
AncestryTestDatabase.with_model do |model|
assert_raise Ancestry::AncestryException do
@ -90,4 +101,22 @@ class DepthCachingTest < ActiveSupport::TestCase
end
end
end
end
# we are already testing generate and parse against static values
# this assumes those are methods are tested and working
def test_ancestry_depth_change
AncestryTestDatabase.with_model do |model|
{
[[], [1]] => +1,
[[1], []] => -1,
[[1], [2]] => 0,
[[1], [1, 2, 3]] => +2,
[[1, 2, 3], [1]] => -2
}.each do |(before, after), diff|
a_before = model.generate_ancestry(before)
a_after = model.generate_ancestry(after)
assert_equal(diff, model.ancestry_depth_change(a_before, a_after))
end
end
end
end

View File

@ -1,27 +1,42 @@
# frozen_string_literal: true
require_relative '../environment'
class DepthConstraintsTest < ActiveSupport::TestCase
def test_descendants_with_depth_constraints
AncestryTestDatabase.with_model :depth => 4, :width => 4, :cache_depth => true do |model, roots|
assert_equal 4, model.roots.first.descendants(:before_depth => 2).count
assert_equal 20, model.roots.first.descendants(:to_depth => 2).count
assert_equal 16, model.roots.first.descendants(:at_depth => 2).count
assert_equal 80, model.roots.first.descendants(:from_depth => 2).count
assert_equal 64, model.roots.first.descendants(:after_depth => 2).count
AncestryTestDatabase.with_model :depth => 4, :width => 4, :cache_depth => true do |model, _roots|
root = model.roots.first
assert_equal 4, root.descendants(:before_depth => 2).count
assert_equal 20, root.descendants(:to_depth => 2).count
assert_equal 16, root.descendants(:at_depth => 2).count
assert_equal 80, root.descendants(:from_depth => 2).count
assert_equal 64, root.descendants(:after_depth => 2).count
assert_equal 4, root.descendant_ids(:before_depth => 2).count
assert_equal 20, root.descendant_ids(:to_depth => 2).count
assert_equal 16, root.descendant_ids(:at_depth => 2).count
assert_equal 80, root.descendant_ids(:from_depth => 2).count
assert_equal 64, root.descendant_ids(:after_depth => 2).count
end
end
def test_subtree_with_depth_constraints
AncestryTestDatabase.with_model :depth => 4, :width => 4, :cache_depth => true do |model, roots|
assert_equal 5, model.roots.first.subtree(:before_depth => 2).count
assert_equal 21, model.roots.first.subtree(:to_depth => 2).count
assert_equal 16, model.roots.first.subtree(:at_depth => 2).count
assert_equal 80, model.roots.first.subtree(:from_depth => 2).count
assert_equal 64, model.roots.first.subtree(:after_depth => 2).count
AncestryTestDatabase.with_model :depth => 4, :width => 4, :cache_depth => true do |model, _roots|
root = model.roots.first
assert_equal 5, root.subtree(:before_depth => 2).count
assert_equal 21, root.subtree(:to_depth => 2).count
assert_equal 16, root.subtree(:at_depth => 2).count
assert_equal 80, root.subtree(:from_depth => 2).count
assert_equal 64, root.subtree(:after_depth => 2).count
assert_equal 5, root.subtree_ids(:before_depth => 2).count
assert_equal 21, root.subtree_ids(:to_depth => 2).count
assert_equal 16, root.subtree_ids(:at_depth => 2).count
assert_equal 80, root.subtree_ids(:from_depth => 2).count
assert_equal 64, root.subtree_ids(:after_depth => 2).count
end
end
def test_ancestors_with_depth_constraints
AncestryTestDatabase.with_model :cache_depth => true do |model|
node1 = model.create!
@ -36,6 +51,25 @@ class DepthConstraintsTest < ActiveSupport::TestCase
assert_equal [node4], leaf.ancestors(:at_depth => -2)
assert_equal [node4, node5], leaf.ancestors(:from_depth => -2)
assert_equal [node5], leaf.ancestors(:after_depth => -2)
# currently ancestor_ids do not support option
end
end
def test_indirects_with_depth_constraints
AncestryTestDatabase.with_model :depth => 4, :width => 4, :cache_depth => true do |model, _roots|
root = model.roots.first
assert_equal 0, root.indirects(:before_depth => 2).count
assert_equal 16, root.indirects(:to_depth => 2).count
assert_equal 16, root.indirects(:at_depth => 2).count
assert_equal 80, root.indirects(:from_depth => 2).count
assert_equal 64, root.indirects(:after_depth => 2).count
assert_equal 0, root.indirect_ids(:before_depth => 2).count
assert_equal 16, root.indirect_ids(:to_depth => 2).count
assert_equal 16, root.indirect_ids(:at_depth => 2).count
assert_equal 80, root.indirect_ids(:from_depth => 2).count
assert_equal 64, root.indirect_ids(:after_depth => 2).count
end
end
@ -55,4 +89,4 @@ class DepthConstraintsTest < ActiveSupport::TestCase
assert_equal [node5, leaf], leaf.path(:after_depth => -2)
end
end
end
end

View File

@ -0,0 +1,111 @@
require_relative '../environment'
# These are only valid for postgres
class DepthVirtualTest < ActiveSupport::TestCase
def test_depth_caching
assert true, "only runs for postgres and recent rails versions"
return unless only_test_virtual_column?
AncestryTestDatabase.with_model :depth => 3, :width => 3, :cache_depth => :virtual do |_model, roots|
roots.each do |lvl0_node, lvl0_children|
assert_equal 0, lvl0_node.depth
lvl0_children.each do |lvl1_node, lvl1_children|
assert_equal 1, lvl1_node.depth
lvl1_children.each do |lvl2_node, _lvl2_children|
assert_equal 2, lvl2_node.depth
end
end
end
end
end
def test_depth_caching_after_subtree_movement
assert true, "only runs for postgres and recent rails versions"
return unless only_test_virtual_column?
AncestryTestDatabase.with_model :depth => 6, :width => 1, :cache_depth => :virtual do |model, _roots|
node = model.at_depth(3).first
node.update(:parent => model.roots.first)
assert_equal(1, node.depth)
node.children.each do |child|
assert_equal(2, child.depth)
child.children.each do |gchild|
assert_equal(3, gchild.depth)
end
end
end
end
def test_depth_scopes
assert true, "only runs for postgres and recent rails versions"
return unless only_test_virtual_column?
AncestryTestDatabase.with_model :depth => 4, :width => 2, :cache_depth => true do |model, _roots|
model.before_depth(2).all? { |node| assert node.depth < 2 }
model.to_depth(2).all? { |node| assert node.depth <= 2 }
model.at_depth(2).all? { |node| assert node.depth == 2 }
model.from_depth(2).all? { |node| assert node.depth >= 2 }
model.after_depth(2).all? { |node| assert node.depth > 2 }
end
end
def test_depth_scopes_without_depth_cache
assert true, "only runs for postgres and recent rails versions"
return unless only_test_virtual_column?
AncestryTestDatabase.with_model :depth => 4, :width => 2 do |model, _roots|
model.before_depth(2).all? { |node| assert node.depth < 2 }
model.to_depth(2).all? { |node| assert node.depth <= 2 }
model.at_depth(2).all? { |node| assert node.depth == 2 }
model.from_depth(2).all? { |node| assert node.depth >= 2 }
model.after_depth(2).all? { |node| assert node.depth > 2 }
end
end
def test_exception_when_rebuilding_depth_cache_for_model_without_depth_caching
assert true, "only runs for postgres and recent rails versions"
return unless only_test_virtual_column?
AncestryTestDatabase.with_model do |model|
assert_raise Ancestry::AncestryException do
model.rebuild_depth_cache!
end
end
end
def test_exception_on_unknown_depth_column
assert true, "only runs for postgres and recent rails versions"
return unless only_test_virtual_column?
AncestryTestDatabase.with_model :cache_depth => true do |model|
assert_raise Ancestry::AncestryException do
model.create!.subtree(:this_is_not_a_valid_depth_option => 42)
end
end
end
# we are already testing generate and parse against static values
# this assumes those are methods are tested and working
def test_ancestry_depth_change
assert true, "only runs for postgres and recent rails versions"
return unless only_test_virtual_column?
AncestryTestDatabase.with_model do |model|
{
[[], [1]] => +1,
[[1], []] => -1,
[[1], [2]] => 0,
[[1], [1, 2, 3]] => +2,
[[1, 2, 3], [1]] => -2
}.each do |(before, after), diff|
a_before = model.generate_ancestry(before)
a_after = model.generate_ancestry(after)
assert_equal(diff, model.ancestry_depth_change(a_before, a_after))
end
end
end
def only_test_virtual_column?
AncestryTestDatabase.postgres? && ActiveRecord.version.to_s >= "7.0"
end
end

View File

@ -1,8 +1,14 @@
# frozen_string_literal: true
require_relative '../environment'
class HasAncestryTreeTest < ActiveSupport::TestCase
def test_default_ancestry_column
AncestryTestDatabase.with_model do |model|
AncestryTestDatabase.with_model skip_ancestry: true, ancestry_column: :ancestry do |model|
model.class_eval do
# explicitly calling has_ancestry so we can be sure no args passed
has_ancestry
end
assert_equal :ancestry, model.ancestry_column
end
end
@ -13,15 +19,6 @@ class HasAncestryTreeTest < ActiveSupport::TestCase
end
end
def test_setting_ancestry_column
AncestryTestDatabase.with_model do |model|
model.ancestry_column = :ancestors
assert_equal :ancestors, model.ancestry_column
model.ancestry_column = :ancestry
assert_equal :ancestry, model.ancestry_column
end
end
def test_invalid_has_ancestry_options
assert_raise Ancestry::AncestryException do
Class.new(ActiveRecord::Base).has_ancestry :this_option_doesnt_exist => 42
@ -32,7 +29,7 @@ class HasAncestryTreeTest < ActiveSupport::TestCase
end
def test_descendants_move_with_node
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |_model, roots|
root1, root2, root3 = roots.map(&:first)
assert_no_difference 'root1.descendants.size' do
assert_difference 'root2.descendants.size', root1.subtree.size do
@ -58,7 +55,7 @@ class HasAncestryTreeTest < ActiveSupport::TestCase
end
def test_modified_parents_set_ancestry_properly
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |_model, roots|
root1, root2, root3 = roots.map(&:first) # r1, r2, r3
root2.update(:parent => root1) # r1 <= r2, r3
root3.update(:parent => root2) # r1 <= r2 <= r3
@ -66,32 +63,8 @@ class HasAncestryTreeTest < ActiveSupport::TestCase
end
end
def test_set_parent_with_non_default_ancestry_column
AncestryTestDatabase.with_model :depth => 3, :width => 3, :ancestry_column => :alternative_ancestry do |model, roots|
root1, root2, _root3 = roots.map(&:first)
assert_no_difference 'root1.descendants.size' do
assert_difference 'root2.descendants.size', root1.subtree.size do
root1.parent = root2
root1.save!
end
end
end
end
def test_set_parent_id
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
root1, root2, _root3 = roots.map(&:first)
assert_no_difference 'root1.descendants.size' do
assert_difference 'root2.descendants.size', root1.subtree.size do
root1.parent_id = root2.id
root1.save!
end
end
end
end
def test_set_parent_id_with_non_default_ancestry_column
AncestryTestDatabase.with_model :depth => 3, :width => 3, :ancestry_column => :alternative_ancestry do |model, roots|
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |_model, roots|
root1, root2, _root3 = roots.map(&:first)
assert_no_difference 'root1.descendants.size' do
assert_difference 'root2.descendants.size', root1.subtree.size do
@ -125,7 +98,7 @@ class HasAncestryTreeTest < ActiveSupport::TestCase
end
def test_primary_key_is_an_integer
AncestryTestDatabase.with_model(extra_columns: { string_id: :string }) do |model|
AncestryTestDatabase.with_model(extra_columns: {string_id: :string}) do |model|
model.primary_key = :string_id
assert !model.primary_key_is_an_integer?

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_relative '../environment'
class ArrangementTest < ActiveSupport::TestCase
@ -7,7 +9,6 @@ class ArrangementTest < ActiveSupport::TestCase
:orphan_strategy => :adopt,
:extra_columns => {:name => :string, :name_path => :string}
) do |model|
model.class_eval do
before_save :before_save_hook
@ -31,11 +32,62 @@ class ArrangementTest < ActiveSupport::TestCase
end
end
def test_update_descendants_with_changed_parent_value
AncestryTestDatabase.with_model(
extra_columns: {name: :string, name_path: :string}
) do |model|
model.class_eval do
before_save :update_name_path
# this example will only work if the name field is unique across all levels
validates :name, :uniqueness => {case_sensitive: false}
def update_name_path
self.name_path = [parent&.name_path, name].compact.join('/')
end
def update_descendants_hook(descendants_clause, old_ancestry, new_ancestry)
super
# Using REPLACE since it is mysql(binary) and sqlite3 friendly.
#
# REGEXP_REPLACE supports an anchor, so it avoids partial matches like 11/21 matching 1/2
# Introducing leading slashes (e.g.: materialized_path2 or /name/name2/) avoids partial matches
# avoid SQL injection
quoted_before = ActiveRecord::Base.connection.quote(name_path_before_last_save)
quoted_after = ActiveRecord::Base.connection.quote(name_path)
descendants_clause["name_path"] = Arel.sql("REPLACE(name_path, #{quoted_before}, #{quoted_after})")
end
end
m1 = model.create!(name: "parent")
m2 = model.create(parent: m1, name: "child")
m3 = model.create(parent: m2, name: "grandchild")
m4 = model.create(parent: m3, name: "grandchild's grand")
assert_equal([m1.id], m2.ancestor_ids)
assert_equal("parent", m1.reload.name_path)
assert_equal("parent/child", m2.reload.name_path)
assert_equal("parent/child/grandchild", m3.reload.name_path)
assert_equal("parent/child/grandchild/grandchild's grand", m4.reload.name_path)
m5 = model.create!(name: "changed")
m2.update!(parent_id: m5.id)
assert_equal("changed", m5.reload.name_path)
assert_equal([m5.id], m2.reload.ancestor_ids)
assert_equal("changed/child", m2.reload.name_path)
assert_equal([m5.id, m2.id], m3.reload.ancestor_ids)
assert_equal("changed/child/grandchild", m3.reload.name_path)
assert_equal([m5.id, m2.id, m3.id], m4.reload.ancestor_ids)
assert_equal("changed/child/grandchild/grandchild's grand", m4.reload.name_path)
end
end
def test_has_ancestry_detects_changes_in_after_save
AncestryTestDatabase.with_model(:extra_columns => {:name => :string, :name_path => :string}) do |model|
model.class_eval do
after_save :after_hook
attr_reader :modified
attr_accessor :modified
def after_hook
@modified = ancestry_changed?
@ -44,11 +96,13 @@ class ArrangementTest < ActiveSupport::TestCase
end
m1 = model.create!(:name => "parent")
m2 = model.create!(:parent => m1, :name => "child")
m2.parent = nil
m2.save!
assert_equal(false, m1.modified)
assert_equal(true, m2.modified)
m2 = m1.children.create!(:name => "child")
m1.modified = m2.modified = nil
m2.update(parent: nil)
assert_nil m1.modified, "hook called on record not changed"
assert m2.modified
end
end
@ -56,7 +110,7 @@ class ArrangementTest < ActiveSupport::TestCase
AncestryTestDatabase.with_model(:extra_columns => {:name => :string, :name_path => :string}) do |model|
model.class_eval do
before_save :before_hook
attr_reader :modified
attr_accessor :modified
def before_hook
@modified = ancestry_changed?
@ -65,11 +119,29 @@ class ArrangementTest < ActiveSupport::TestCase
end
m1 = model.create!(:name => "parent")
m2 = model.create!(:parent => m1, :name => "child")
m2.parent = nil
m2.save!
assert_equal(false, m1.modified)
assert_equal(true, m2.modified)
m2 = m1.children.create!(:name => "child")
m1.modified = m2.modified = nil
m2.update!(parent: nil)
assert_nil m1.modified, "hook called on record not changed"
assert m2.modified
end
end
# see f94b22ba https://github.com/stefankroes/ancestry/pull/263
def test_node_creation_in_after_commit
AncestryTestDatabase.with_model do |model|
children = []
model.instance_eval do
attr_accessor :idx
after_commit do
children << self.children.create!(:idx => idx - 1) if idx > 0
end
end
model.create!(:idx => 3)
assert_equal [1, 2, 3], children.first.ancestor_ids
end
end
end

View File

@ -1,8 +1,10 @@
# frozen_string_literal: true
require_relative '../environment'
class IntegrityCheckingAndRestaurationTest < ActiveSupport::TestCase
def test_integrity_checking
AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, roots|
AncestryTestDatabase.with_model :width => 3, :depth => 3 do |model, _roots|
# Check that there are no errors on a valid tree
assert_nothing_raised do
model.check_ancestry_integrity!
@ -49,7 +51,7 @@ class IntegrityCheckingAndRestaurationTest < ActiveSupport::TestCase
end
end
def assert_integrity_restoration model
def assert_integrity_restoration(model)
assert_raise Ancestry::AncestryIntegrityException do
model.check_ancestry_integrity!
end
@ -57,8 +59,8 @@ class IntegrityCheckingAndRestaurationTest < ActiveSupport::TestCase
assert_nothing_raised do
model.check_ancestry_integrity!
end
assert model.all.any? {|node| node.ancestry.present? }, "Expected some nodes not to be roots"
assert_equal model.count, model.roots.collect {|node| node.descendants.count + 1 }.sum
assert model.all.any?(&:has_parent?), "Expected some nodes not to be roots"
assert_equal model.count, model.roots.collect { |node| node.descendants.count + 1 }.sum
end
def test_integrity_restoration

View File

@ -0,0 +1,108 @@
# frozen_string_literal: true
require_relative '../environment'
class MaterializedPath2Test < ActiveSupport::TestCase
def test_ancestry_column_mp2
assert true, "this runs if materialized path2"
return unless AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
root = model.create!
node = model.new
# new node
assert_ancestry node, "/", db: nil
assert_raises(Ancestry::AncestryException) { node.child_ancestry }
# saved
node.save!
assert_ancestry node, "/", child: "/#{node.id}/"
# changed
node.ancestor_ids = [root.id]
assert_ancestry node, "/#{root.id}/", db: "/", child: "/#{node.id}/"
# changed saved
node.save!
assert_ancestry node, "/#{root.id}/", child: "/#{root.id}/#{node.id}/"
# reloaded
node.reload
assert_ancestry node, "/#{root.id}/", child: "/#{root.id}/#{node.id}/"
# fresh node
node = model.find(node.id)
assert_ancestry node, "/#{root.id}/", child: "/#{root.id}/#{node.id}/"
end
end
def test_ancestry_column_validation
assert true, "this runs if materialized path2"
return unless AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
node = model.create # assuming id == 1
['/3/', '/10/2/', '/9/4/30/', model.ancestry_root].each do |value|
node.send :write_attribute, model.ancestry_column, value
assert node.sane_ancestor_ids?
assert node.valid?
end
end
end
def test_ancestry_column_validation_fails
assert true, "this runs if materialized path2"
return unless AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
node = model.create
['/a/', '/a/b/', '/-34/'].each do |value|
node.send :write_attribute, model.ancestry_column, value
refute node.sane_ancestor_ids?
refute node.valid?
end
end
end
def test_ancestry_column_validation_string_key
assert true, "this runs if materialized path2"
return unless AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model(:id => :string, :primary_key_format => /[a-z]/) do |model|
node = model.create(:id => 'z')
['/a/', '/a/b/', '/a/b/c/', model.ancestry_root].each do |value|
node.send :write_attribute, model.ancestry_column, value
assert node.valid?
end
end
end
def test_ancestry_column_validation_string_key_fails
assert true, "this runs if materialized path2"
return unless AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model(:id => :string, :primary_key_format => /[a-z]/) do |model|
node = model.create(:id => 'z')
['/1/', '/1/2/', '/a-b/c/'].each do |value|
node.send :write_attribute, model.ancestry_column, value
refute node.valid?
end
end
end
def test_ancestry_validation_exclude_self
assert true, "this runs if materialized path2"
return unless AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
parent = model.create!
child = parent.children.create!
assert_raise ActiveRecord::RecordInvalid do
parent.parent = child
refute parent.sane_ancestor_ids?
parent.save!
end
end
end
end

View File

@ -0,0 +1,110 @@
# frozen_string_literal: true
require_relative '../environment'
class MaterializedPathTest < ActiveSupport::TestCase
def test_ancestry_column_values
assert true, "this runs if materialized path"
return if AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
root = model.create!
node = model.new
# new node
assert_ancestry node, nil
assert_raises(Ancestry::AncestryException) { node.child_ancestry }
# saved
node.save!
assert_ancestry node, nil, child: node.id.to_s
# changed
node.ancestor_ids = [root.id]
assert_ancestry node, root.id.to_s, db: nil, child: node.id.to_s
# changed saved
node.save!
assert_ancestry node, root.id.to_s, child: "#{root.id}/#{node.id}"
# reloaded
node.reload
assert_ancestry node, root.id.to_s, child: "#{root.id}/#{node.id}"
# fresh node
node = model.find(node.id)
assert_ancestry node, root.id.to_s, child: "#{root.id}/#{node.id}"
end
end
def test_ancestry_column_validation
assert true, "this runs if materialized path"
return if AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
node = model.create # assuming id == 1
['3', '10/2', '9/4/30', model.ancestry_root].each do |value|
node.send :write_attribute, model.ancestry_column, value
assert node.sane_ancestor_ids?
assert node.valid?
end
end
end
def test_ancestry_column_validation_fails
assert true, "this runs if materialized path"
return if AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
node = model.create
['a', 'a/b', '-34'].each do |value|
node.send :write_attribute, model.ancestry_column, value
refute node.sane_ancestor_ids?
refute node.valid?
end
end
end
def test_ancestry_column_validation_string_key
assert true, "this runs if materialized path"
return if AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model(:id => :string, :primary_key_format => /[a-z]/) do |model|
node = model.create(:id => 'z')
['a', 'a/b', 'a/b/c', model.ancestry_root].each do |value|
node.send :write_attribute, model.ancestry_column, value
assert node.sane_ancestor_ids?
assert node.valid?
end
end
end
def test_ancestry_column_validation_string_key_fails
assert true, "this runs if materialized path"
return if AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model(:id => :string, :primary_key_format => /[a-z]/) do |model|
node = model.create(:id => 'z')
['1', '1/2', 'a-b/c'].each do |value|
node.send :write_attribute, model.ancestry_column, value
refute node.sane_ancestor_ids?
refute node.valid?
end
end
end
def test_ancestry_validation_exclude_self
assert true, "this runs if materialized path"
return if AncestryTestDatabase.materialized_path2?
AncestryTestDatabase.with_model do |model|
parent = model.create!
child = parent.children.create!
assert_raise ActiveRecord::RecordInvalid do
parent.parent = child
refute parent.sane_ancestor_ids?
parent.save!
end
end
end
end

View File

@ -1,38 +1,18 @@
# frozen_string_literal: true
require_relative '../environment'
class OphanStrategiesTest < ActiveSupport::TestCase
def test_default_orphan_strategy
AncestryTestDatabase.with_model do |model|
assert_equal :destroy, model.orphan_strategy
end
end
def test_non_default_orphan_strategy
AncestryTestDatabase.with_model :orphan_strategy => :rootify do |model|
assert_equal :rootify, model.orphan_strategy
end
end
def test_setting_orphan_strategy
AncestryTestDatabase.with_model do |model|
model.orphan_strategy = :rootify
assert_equal :rootify, model.orphan_strategy
model.orphan_strategy = :destroy
assert_equal :destroy, model.orphan_strategy
end
end
def test_setting_invalid_orphan_strategy
AncestryTestDatabase.with_model do |model|
AncestryTestDatabase.with_model skip_ancestry: true do |model|
assert_raise Ancestry::AncestryException do
model.orphan_strategy = :non_existent_orphan_strategy
model.has_ancestry orphan_strategy: :non_existent_orphan_strategy
end
end
end
def test_orphan_rootify_strategy
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
model.orphan_strategy = :rootify
AncestryTestDatabase.with_model orphan_strategy: :rootify, :depth => 3, :width => 3 do |_model, roots|
root = roots.first.first
children = root.children.to_a
root.destroy
@ -45,8 +25,19 @@ class OphanStrategiesTest < ActiveSupport::TestCase
end
def test_orphan_destroy_strategy
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
model.orphan_strategy = :destroy
AncestryTestDatabase.with_model orphan_strategy: :destroy, :depth => 3, :width => 3 do |model, roots|
model.class_eval do
# verify we are destroying leafs to child
# some implementations leverage the parent
before_destroy :verify_parent_exists
def verify_parent_exists
if has_parent? && !self.class.where(:id => parent_id).exists?
raise "issues with #{id} (ancestry #{ancestry || "root"})"
end
end
end
root = roots.first.first
assert_difference 'model.count', -root.subtree.size do
root.destroy
@ -59,8 +50,7 @@ class OphanStrategiesTest < ActiveSupport::TestCase
end
def test_orphan_restrict_strategy
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
model.orphan_strategy = :restrict
AncestryTestDatabase.with_model orphan_strategy: :restrict, :depth => 3, :width => 3 do |_model, roots|
root = roots.first.first
assert_raise Ancestry::AncestryException do
root.destroy
@ -72,37 +62,113 @@ class OphanStrategiesTest < ActiveSupport::TestCase
end
def test_orphan_adopt_strategy
AncestryTestDatabase.with_model do |model|
model.orphan_strategy = :adopt # set the orphan strategy as paerntify
n1 = model.create! #create a root node
n2 = model.create!(:parent => n1) #create child with parent=root
n3 = model.create!(:parent => n2) #create child with parent=n2, depth = 2
n4 = model.create!(:parent => n2) #create child with parent=n2, depth = 2
n5 = model.create!(:parent => n4) #create child with parent=n4, depth = 3
AncestryTestDatabase.with_model orphan_strategy: :adopt do |model|
n1 = model.create! # create a root node
n2 = model.create!(:parent => n1) # create child with parent=root
n3 = model.create!(:parent => n2) # create child with parent=n2, depth = 2
n4 = model.create!(:parent => n2) # create child with parent=n2, depth = 2
n5 = model.create!(:parent => n4) # create child with parent=n4, depth = 3
n2.destroy # delete a node with desecendants
assert_equal(model.find(n3.id).parent,n1, "orphan's not parentified" )
assert_equal(model.find(n5.id).ancestor_ids, [n1.id,n4.id], "ancestry integrity not maintained")
n3.reload
n5.reload
assert_equal n3.parent_id, n1.id, "orphan's not parentified"
assert_equal n5.ancestor_ids, [n1.id, n4.id], "ancestry integrity not maintained"
n1.destroy # delete a root node with desecendants
assert_nil(model.find(n3.id).ancestry," new root node has no empty ancestry string")
assert_equal(model.find(n3.id).valid?, true, " new root node is not valid")
assert_nil(model.find(n3.id).parent_id, " Children of the deleted root not rootfied")
assert_equal(model.find(n5.id).ancestor_ids, [n4.id], "ancestry integrity not maintained")
n3.reload
n5.reload
assert_nil n3.parent_id, " new root node should have no parent"
assert n3.valid?, " new root node is not valid"
assert_equal n5.ancestor_ids, [n4.id], "ancestry integrity not maintained"
end
end
# DEPRECATED - please see test_apply_orphan_strategy_none for pattern instead
def test_override_apply_orphan_strategy
AncestryTestDatabase.with_model orphan_strategy: :destroy do |model, _roots|
root = model.create!
child = model.create!(:parent => root)
model.class_eval do
def apply_orphan_strategy
# disabling destoy callback
end
end
assert_difference 'model.count', -1 do
root.destroy
end
# this should not throw an ActiveRecord::RecordNotFound exception
assert child.reload.root_id == root.id
end
end
def test_apply_orphan_strategy_none
AncestryTestDatabase.with_model orphan_strategy: :none do |model, _roots|
root = model.create!
child = model.create!(:parent => root)
model.class_eval do
def apply_orphan_strategy
raise "this should not be called"
end
end
assert_difference 'model.count', -1 do
root.destroy
end
# this record should still exist
assert child.reload.root_id == root.id
end
end
def test_apply_orphan_strategy_custom
AncestryTestDatabase.with_model orphan_strategy: :none do |model|
model.class_eval do
before_destroy :apply_orphan_strategy_abc
def apply_orphan_strategy_abc
apply_orphan_strategy_destroy
end
end
root = model.create!
3.times { root.children.create! }
model.create! # a node that is not affected
assert_difference 'model.count', -4 do
root.destroy
end
end
end
# Not supported. Keeping around to explore for future uses.
def test_apply_orphan_strategy_custom_unsupported
AncestryTestDatabase.with_model skip_ancestry: true do |model|
model.class_eval do
# needs to be defined before calling has_ancestry
def apply_orphan_strategy_abc
apply_orphan_strategy_destroy
end
has_ancestry orphan_strategy: :abc, ancestry_column: AncestryTestDatabase.ancestry_column
end
root = model.create!
3.times { root.children.create! }
model.create! # a node that is not affected
assert_difference 'model.count', -4 do
root.destroy
end
end
end
def test_basic_delete
AncestryTestDatabase.with_model do |model|
n1 = model.create! #create a root node
n2 = model.create!(:parent => n1) #create child with parent=root
n1 = model.create! # create a root node
n2 = model.create!(:parent => n1) # create child with parent=root
n2.destroy!
model.find(n1.id) # parent should exist
model.find(n1.id) # parent should exist
n1 = model.create! #create a root node
n2 = model.create!(:parent => n1) #create child with parent=root
n1 = model.create! # create a root node
n2 = model.create!(:parent => n1) # create child with parent=root
n1.destroy!
assert_nil(model.find_by(:id => n2.id)) # child should not exist
n1 = model.create! #create a root node
n1 = model.create! # create a root node
n1.destroy!
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
require_relative '../environment'
class RelationsTest < ActiveSupport::TestCase
def test_root_found
AncestryTestDatabase.with_model do |model|
parent = model.create
child = model.create!(:ancestor_ids => [parent.id])
assert_equal(parent, child.root)
end
end
def test_root_not_found
AncestryTestDatabase.with_model do |model|
record = model.create
# setting the parent_id to something not valid
record.update_attribute(:ancestor_ids, [record.id + 1])
assert_equal record.root, record
end
end
def test_parent_found
AncestryTestDatabase.with_model do |model|
parent = model.create
child = model.create!(:ancestor_ids => [parent.id])
assert_equal(parent, child.parent)
end
end
def test_parent_not_found
AncestryTestDatabase.with_model do |model|
record = model.create
# setting the parent_id to something not valid
record.update_attribute(:ancestor_ids, [record.id + 1])
assert_nil record.parent
end
end
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
require_relative '../environment'
# all class nodes used to look up objects belong here
class ScopesTest < ActiveSupport::TestCase
def test_scopes
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
@ -38,41 +41,32 @@ class ScopesTest < ActiveSupport::TestCase
def test_chained_scopes
AncestryTestDatabase.with_model :depth => 2, :width => 2 do |model, roots|
# before Rails 4.0, the last scope in chained scopes used to ignore earlier ones
# which resulted in: `Post.active.inactive.to_a` == `Post.inactive.to_a`
# https://github.com/rails/rails/commit/cd26b6ae
# therefore testing against later AR versions only
if ActiveRecord::VERSION::MAJOR >=4
roots.each do |root, children|
# the first scope limits the second scope
assert_empty model.children_of(root).roots
assert_empty model.children_of(root.id).roots
# object id in the second scope argument should be found without being affected by the first scope
assert_equal model.children_of(root).children_of(root).to_a, model.children_of(root).to_a
assert_equal model.children_of(root.id).children_of(root.id).to_a, model.children_of(root.id).to_a
end
roots.each do |root, _children|
# the first scope limits the second scope
assert_empty model.children_of(root).roots
assert_empty model.children_of(root.id).roots
# object id in the second scope argument should be found without being affected by the first scope
assert_equal model.children_of(root).children_of(root).to_a, model.children_of(root).to_a
assert_equal model.children_of(root.id).children_of(root.id).to_a, model.children_of(root.id).to_a
end
end
end
def test_order_by
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
# not thrilled with this. mac postgres has odd sorting requirements
if ENV["DB"].to_s =~ /pg/ && RUBY_PLATFORM !~ /x86_64-darwin/
expected = model.all.sort_by { |m| [m.ancestor_ids.map(&:to_s).join, m.id.to_i] }
else
expected = model.all.sort_by { |m| [m.ancestor_ids.map(&:to_s), m.id.to_i] }
end
def test_ordered_by_ancestry
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, _roots|
# Some pg databases do not use symbols in sorting
# if this is failing, try tweaking the collation of your ancestry columns
expected = model.all.sort_by { |m| [m.ancestor_ids.map(&:to_s), m.id.to_i] }
actual = model.ordered_by_ancestry_and(:id)
assert_equal expected.map { |r| [r.ancestor_ids, r.id.to_s] }, actual.map { |r| [r.ancestor_ids, r.id.to_s] }
assert_equal (expected.map { |r| [r.ancestor_ids, r.id.to_s] }), (actual.map { |r| [r.ancestor_ids, r.id.to_s] })
end
end
def test_order_by_reverse
AncestryTestDatabase.with_model(:width => 1, :depth => 3) do |model, roots|
AncestryTestDatabase.with_model(:width => 1, :depth => 3) do |model, _roots|
child = model.last
assert child
assert_nothing_raised do #IrreversibleOrderError
assert_nothing_raised do # IrreversibleOrderError
assert child.ancestors.last
end
end
@ -112,9 +106,9 @@ class ScopesTest < ActiveSupport::TestCase
model.class_eval do
define_method :after_create_callback do
# We don't want to be in the #children scope here when creating the child
self.parent
parent
self.parent_id = record.id if record
self.root
root
end
end
@ -127,8 +121,8 @@ class ScopesTest < ActiveSupport::TestCase
AncestryTestDatabase.with_model(:extra_columns => {:name => :string}) do |model|
root = model.create
record = root.children.create
# this should not throw an exception
record.reload.parent.children.find_or_create_by! :name => 'abc'
assert true, "this should not throw an exception"
end
end
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_relative '../environment'
class IntegrityCheckingAndRestaurationTest < ActiveSupport::TestCase

View File

@ -1,12 +1,13 @@
# frozen_string_literal: true
require_relative '../environment'
class SortByAncestryTest < ActiveSupport::TestCase
# in a perfect world, we'd only follow the CORRECT=true case
# but when not enough information is available, the STRICT=true case is good enough
#
# these flags are to allow multiple values for correct for tests
# In a perfect world, we'd only follow the CORRECT=true case
# This highlights where/why a non-correct sorting order is returned
CORRECT = (ENV["CORRECT"] == "true")
STRICT = (ENV["STRICT"] == "true")
RANK_SORT = ->(a, b) { a.rank <=> b.rank }
# tree is of the form:
# - n1
@ -18,7 +19,7 @@ class SortByAncestryTest < ActiveSupport::TestCase
# @returns [Array<model>] list of nodes
def build_tree(model)
# inflate the node id to test id wrap around edge cases
ENV["NODES"].to_i.times { model.create!.destroy } if ENV["NODES"]
ENV["NODES"]&.to_i&.times { model.create!.destroy }
n1 = model.create!
n2 = model.create!(:parent => n1)
@ -31,28 +32,41 @@ class SortByAncestryTest < ActiveSupport::TestCase
[n1, n2, n3, n4, n5, n6]
end
# nodes among the same parent have an ambigious order
# so they keep the same order as input
# also note, parent nodes do come in first
def test_sort_by_ancestry_full_tree
AncestryTestDatabase.with_model do |model|
n1, n2, n3, n4, n5, n6 = build_tree(model)
records = model.sort_by_ancestry(model.all.order(:id).reverse)
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:id => :desc))
assert_equal [n1, n5, n6, n2, n4, n3].map(&:id), records.map(&:id)
end
end
# tree is of the form:
# - x
# - x
# - n3
# - n4
def test_sort_by_ancestry_no_parents_siblings
AncestryTestDatabase.with_model do |model|
_, _, n3, n4, _, _ = build_tree(model)
assert_equal [n4, n3].map(&:id), model.sort_by_ancestry([n4, n3]).map(&:id)
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:id => :desc).offset(3).take(2))
assert_equal [n4, n3].map(&:id), records.map(&:id)
end
end
# TODO: thinking about dropping this one
# only keep if we can find a way to sort in the db
def test_sort_by_ancestry_no_parents_same_level
AncestryTestDatabase.with_model do |model|
_, _, n3, n4, n5, _ = build_tree(model)
assert_equal [n5, n4, n3].map(&:id), model.sort_by_ancestry([n5, n4, n3]).map(&:id)
records = [n5, n4, n3]
# records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:id => :desc).offset(3).take(3))
assert_equal [n5, n4, n3].map(&:id), records.map(&:id)
end
end
@ -60,19 +74,35 @@ class SortByAncestryTest < ActiveSupport::TestCase
AncestryTestDatabase.with_model do |model|
n1, n2, _, _, n5, _ = build_tree(model)
assert_equal [n1, n5, n2].map(&:id), model.sort_by_ancestry([n5, n2, n1]).map(&:id)
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:id => :desc).offset(0).take(3))
assert_equal [n1, n5, n2].map(&:id), records.map(&:id)
end
end
# - n1
# - x
# - n4
# - n5
#
# Issue:
#
# since the nodes are not at the same level, we don't have
# a way to know if n4 comes before or after n5
#
# n1 will always come first since it is a parent of both
# Since we don't have n2, to bring n4 before n5, we leave in input order
# TODO: thinking about dropping this test
# can't think of a way that these records would come back with sql order
def test_sort_by_ancestry_missing_parent_middle_of_tree
AncestryTestDatabase.with_model do |model|
n1, _, _, n4, n5, _ = build_tree(model)
records = model.sort_by_ancestry([n5, n4, n1])
if (!CORRECT) && (STRICT || records[1] == n5)
assert_equal [n1, n5, n4].map(&:id), records.map(&:id)
else
if CORRECT
assert_equal [n1, n4, n5].map(&:id), records.map(&:id)
else
assert_equal [n1, n5, n4].map(&:id), records.map(&:id)
end
end
end
@ -87,7 +117,8 @@ class SortByAncestryTest < ActiveSupport::TestCase
# @returns [Array<model>] list of ranked nodes
def build_ranked_tree(model)
# inflate the node id to test id wrap around edge cases
ENV["NODES"].to_i.times { model.create!.destroy } if ENV["NODES"]
# NODES=4..9 seem like edge cases
ENV["NODES"]&.to_i&.times { model.create!.destroy }
n1 = model.create!(:rank => 0)
n2 = model.create!(:rank => 1)
@ -100,12 +131,14 @@ class SortByAncestryTest < ActiveSupport::TestCase
[n1, n2, n3, n4, n5, n6]
end
# TODO: thinking about dropping this one
# Think we need to assume that best effort was done in the database:
# ordered_by_ancestry_and(:id => :desc) or order(:ancestry).order(:id => :desc)
def test_sort_by_ancestry_with_block_full_tree
AncestryTestDatabase.with_model :extra_columns => {:rank => :integer} do |model|
n1, n2, n3, n4, n5, n6 = build_ranked_tree(model)
sort = -> (a, b) { a.rank <=> b.rank }
records = model.sort_by_ancestry(model.all.order(:id).reverse, &sort)
records = model.sort_by_ancestry(model.all.order(:id => :desc), &RANK_SORT)
assert_equal [n1, n5, n3, n2, n4, n6].map(&:id), records.map(&:id)
end
end
@ -115,7 +148,7 @@ class SortByAncestryTest < ActiveSupport::TestCase
AncestryTestDatabase.with_model :extra_columns => {:rank => :integer} do |model|
n1, n2, n3, n4, n5, n6 = build_ranked_tree(model)
records = model.sort_by_ancestry(model.all.order(:rank))
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:rank))
assert_equal [n1, n5, n3, n2, n4, n6].map(&:id), records.map(&:id)
end
end
@ -123,22 +156,34 @@ class SortByAncestryTest < ActiveSupport::TestCase
def test_sort_by_ancestry_with_block_all_parents_some_children
AncestryTestDatabase.with_model :extra_columns => {:rank => :integer} do |model|
n1, n2, _, _, n5, _ = build_ranked_tree(model)
sort = -> (a, b) { a.rank <=> b.rank }
assert_equal [n1, n5, n2].map(&:id), model.sort_by_ancestry([n1, n2, n5], &sort).map(&:id)
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:rank).take(3), &RANK_SORT)
assert_equal [n1, n5, n2].map(&:id), records.map(&:id)
end
end
# seems the best we can do is to have [5,3] + [4,6]
# if we follow input order, we can end up with either result
# a) n3 moves all the way to the right or b) n5 moves all the way to the left
# TODO: find a way to rank missing nodes
# It is tricky when we are using ruby to sort nodes and the parent
# nodes (i.e.: n1, n2) are not in ruby to be sorted. We either sort
# them by input order or by id order.
#
# - x (0)
# - n5 (0)
# - n3 (3)
# - x (1)
# - n4 (0)
# - n6 (1)
# We can sort [n5, n3] + [n4, n6]
# a) n3 moves all the way to the right to join n5 OR
# b) n5 moves all the way to the left to join n3
# Issue:
# we do not know if the parent of n5 (n1) comes before or after the parent of n4 (n2)
# So they should stay in their original order
# But again, it is indeterministic which way the 2 pairs go
def test_sort_by_ancestry_with_block_no_parents_all_children
AncestryTestDatabase.with_model :extra_columns => {:rank => :integer} do |model|
_, _, n3, n4, n5, n6 = build_ranked_tree(model)
sort = -> (a, b) { a.rank <=> b.rank }
records = model.sort_by_ancestry([n3, n4, n5, n6], &sort)
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:rank).offset(2), &RANK_SORT)
if CORRECT || records[0] == n5
assert_equal [n5, n3, n4, n6].map(&:id), records.map(&:id)
else
@ -147,34 +192,46 @@ class SortByAncestryTest < ActiveSupport::TestCase
end
end
# TODO: nodes need to follow original ordering
# - x (0)
# - x
# - n3 (3)
# - n2 (1)
# - n4 (0)
# - x
# Issue: n2 will always go before n4, n5.
# But n1 is not available to put n3 before the n2 tree.
# not sure why it doesn't follow the input order
#
# NOTE: even for partial trees, if the input records are ranked, the output works
def test_sort_by_ancestry_with_sql_sort_paginated_missing_parents_and_children
AncestryTestDatabase.with_model :extra_columns => {:rank => :integer} do |model|
_, n2, n3, n4, _, _ = build_ranked_tree(model)
_, n2, n3, n4, n5, _ = build_ranked_tree(model)
records = model.sort_by_ancestry([n2, n4, n3])
if (!CORRECT) && (STRICT || records[0] == n2)
assert_equal [n2, n4, n3].map(&:id), records.map(&:id)
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:rank).offset(1).take(4))
if CORRECT
assert_equal [n3, n2, n4, n5].map(&:id), records.map(&:id)
else
assert_equal [n3, n2, n4].map(&:id), records.map(&:id)
assert_equal [n2, n4, n5, n3].map(&:id), records.map(&:id)
end
end
end
# in a perfect world, the second case would be matched
# but since presorting is not used, the best we can assume from input order is that n1 > n2
# TODO: find a way to rank missing nodes
# same as above but using sort block
# - x (0)
# - x
# - n3 (3)
# - n2 (1)
# - n4 (0)
# - n5
def test_sort_by_ancestry_with_block_paginated_missing_parents_and_children
AncestryTestDatabase.with_model :extra_columns => {:rank => :integer} do |model|
_, n2, n3, n4, _, _ = build_ranked_tree(model)
sort = -> (a, b) { a.rank <=> b.rank }
_, n2, n3, n4, n5, _ = build_ranked_tree(model)
records = model.sort_by_ancestry([n2, n4, n3], &sort)
if (!CORRECT) && (STRICT || records[0] == n2)
assert_equal [n2, n4, n3].map(&:id), records.map(&:id)
records = model.sort_by_ancestry(model.all.ordered_by_ancestry_and(:rank).offset(1).take(4), &RANK_SORT)
if CORRECT
assert_equal [n3, n2, n4, n5].map(&:id), records.map(&:id)
else
assert_equal [n3, n2, n4].map(&:id), records.map(&:id)
assert_equal [n2, n4, n5, n3].map(&:id), records.map(&:id)
end
end
end

View File

@ -1,37 +1,93 @@
# frozen_string_literal: true
require_relative '../environment'
class StiSupportTest < ActiveSupport::TestCase
def test_sti_support
AncestryTestDatabase.with_model :extra_columns => {:type => :string} do |model|
subclass1 = Object.const_set 'Subclass1', Class.new(model)
(class << subclass1; self; end).send :define_method, :model_name do; Struct.new(:human, :underscore).new 'Subclass1', 'subclass1'; end
subclass2 = Object.const_set 'Subclass2', Class.new(model)
(class << subclass2; self; end).send :define_method, :model_name do; Struct.new(:human, :underscore).new 'Subclass1', 'subclass1'; end
Object.const_set 'Subclass1', Class.new(model)
Object.const_set 'Subclass2', Class.new(model)
node1 = subclass1.create!
node2 = subclass2.create! :parent => node1
node3 = subclass1.create! :parent => node2
node4 = subclass2.create! :parent => node3
node5 = subclass1.create! :parent => node4
node1 = Subclass1.create!
node2 = Subclass2.create! :parent => node1
node3 = Subclass1.create! :parent => node2
node4 = Subclass2.create! :parent => node3
node5 = Subclass1.create! :parent => node4
model.all.each do |node|
assert [subclass1, subclass2].include?(node.class)
assert [Subclass1, Subclass2].include?(node.class)
end
assert_equal [node2.id, node3.id, node4.id, node5.id], node1.descendants.map(&:id)
assert_equal [node1.id, node2.id, node3.id, node4.id, node5.id], node1.subtree.map(&:id)
assert_equal [node1.id, node2.id, node3.id, node4.id], node5.ancestors.map(&:id)
assert_equal [node1.id, node2.id, node3.id, node4.id, node5.id], node5.path.map(&:id)
Object.send :remove_const, 'Subclass1'
Object.send :remove_const, 'Subclass2'
end
end
def test_sti_support_with_from_subclass
AncestryTestDatabase.with_model :extra_columns => {:type => :string} do |model|
AncestryTestDatabase.with_model :ancestry_column => :t1,
:skip_ancestry => true,
:counter_cache => true,
:extra_columns => {:type => :string} do |model|
subclass1 = Object.const_set 'SubclassWithAncestry', Class.new(model)
subclass2 = Object.const_set 'SubclassWithAncestry2', Class.new(model)
subclass1b = Object.const_set 'SubclassOfSubclassWithAncestry', Class.new(subclass1)
subclass1.has_ancestry
# we are defining it one level below the parent ("model" class)
subclass1.has_ancestry :ancestry_column => :t1, :counter_cache => true
subclass2.has_ancestry :ancestry_column => :t1
subclass1.create!
# ensure class variables are distinct
assert subclass1.respond_to?(:counter_cache_column)
refute subclass2.respond_to?(:counter_cache_column)
root = subclass1.create!
# this was the line that was blowing up for this orginal feature
child = subclass1.create!(:parent => root)
child2 = subclass1b.create!(:parent => root)
# counter caches across class lines (going up to parent)
assert_equal 2, root.reload.children_count
# children
assert_equal [child, child2], root.children.order(:id)
assert_equal root, child.parent
Object.send :remove_const, 'SubclassWithAncestry'
Object.send :remove_const, 'SubclassWithAncestry2'
Object.send :remove_const, 'SubclassOfSubclassWithAncestry'
end
end
end
def test_sti_support_for_counter_cache
AncestryTestDatabase.with_model :counter_cache => true, :extra_columns => {:type => :string} do |model|
# NOTE: had to use subclasses other than Subclass1/Subclass2 from above
# due to (I think) Rails caching those STI classes and that not getting
# reset between specs
Object.const_set 'Subclass3', Class.new(model)
Object.const_set 'Subclass4', Class.new(model)
node1 = Subclass3.create!
node2 = Subclass4.create! :parent => node1
node3 = Subclass3.create! :parent => node1
node4 = Subclass4.create! :parent => node3
node5 = Subclass3.create! :parent => node4
assert_equal 2, node1.reload.children_count
assert_equal 0, node2.reload.children_count
assert_equal 1, node3.reload.children_count
assert_equal 1, node4.reload.children_count
assert_equal 0, node5.reload.children_count
Object.send :remove_const, 'Subclass3'
Object.send :remove_const, 'Subclass4'
end
end
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_relative '../environment'
class TouchingTest < ActiveSupport::TestCase
@ -6,13 +8,16 @@ class TouchingTest < ActiveSupport::TestCase
:extra_columns => {:name => :string, :updated_at => :datetime},
:touch => false
) do |model|
wayback = Time.new(1984)
recently = Time.now - 1.minute
yesterday = Time.now - 1.day
parent = model.create!(:updated_at => yesterday)
child = model.create!(:updated_at => yesterday, :parent => parent)
parent = model.create!
child = model.create!(:parent => parent)
model.update_all(:updated_at => wayback)
child.update(:name => "Changed")
assert_equal yesterday.utc.change(:usec => 0), parent.updated_at.utc.change(:usec => 0)
child.reload.update(:name => "Changed")
assert child.reload.updated_at >= recently, "record updated_at was not changed"
assert parent.reload.updated_at < recently, "parent updated_at was changed"
end
end
@ -21,47 +26,75 @@ class TouchingTest < ActiveSupport::TestCase
:extra_columns => {:updated_at => :datetime},
:touch => true
) do |model|
way_back = Time.new(1984)
recently = Time.now - 1.minute
parent_1 = model.create!(:updated_at => way_back)
parent_2 = model.create!(:updated_at => way_back)
child_1_1 = model.create!(:updated_at => way_back, :parent => parent_1)
child_1_2 = model.create!(:updated_at => way_back, :parent => parent_1)
grandchild_1_1_1 = model.create!(:updated_at => way_back, :parent => child_1_1)
grandchild_1_1_2 = model.create!(:updated_at => way_back, :parent => child_1_1)
parent_1 = model.create!
parent_2 = model.create!
child_1_1 = model.create!(:parent => parent_1)
child_1_2 = model.create!(:parent => parent_1)
grandchild_1_1_1 = model.create!(:parent => child_1_1)
grandchild_1_1_2 = model.create!(:parent => child_1_1)
# creating children update all the fields. this clears them back
model.update_all(:updated_at => way_back)
grandchild_1_1_1.parent = parent_2
grandchild_1_1_1.save!
grandchild_1_1_1.reload.update!(parent: parent_2)
assert grandchild_1_1_1.reload.updated_at > recently, "record was not touched"
assert child_1_1.reload.updated_at > recently, "old parent was not touched"
assert parent_1.reload.updated_at > recently, "old grandparent was not touched"
assert parent_2.reload.updated_at > recently, "new parent was not touched"
assert grandchild_1_1_1.reload.updated_at >= recently, "record was not touched"
assert child_1_1.reload.updated_at >= recently, "old parent was not touched"
assert parent_1.reload.updated_at >= recently, "old grandparent was not touched"
assert parent_2.reload.updated_at >= recently, "new parent was not touched"
assert_equal way_back, grandchild_1_1_2.reload.updated_at, "old sibling was touched"
assert_equal way_back, child_1_2.reload.updated_at, "unrelated record was touched"
end
end
def test_touch_propogates_multiple_levels
AncestryTestDatabase.with_model(:extra_columns => {:name => :string, :updated_at => :datetime}, :touch => true) do |model|
way_back = Time.new(1984)
recently = Time.now - 1.minute
node1 = model.create!(:name => "n1")
node2 = model.create!(:name => "n2")
node3 = model.create!(:name => "n3")
node11 = model.create!(:name => "n11", :parent => node1)
node111 = model.create!(:name => "n111", :parent => node11)
node1111 = model.create!(:name => "n1111", :parent => node111)
# creating children update all the fields. this clears them back
model.update_all(:updated_at => way_back)
node11.reload.update!(:parent => node2)
assert node1.reload.updated_at >= recently, "old parent was not touched"
assert node2.reload.updated_at >= recently, "new parent was not touched"
assert node3.reload.updated_at < recently, "uncle was touched"
assert node11.reload.updated_at >= recently, "record was not touched"
assert node111.reload.updated_at >= recently, "child was not touched"
assert node1111.reload.updated_at >= recently, "child was not touched"
end
end
# this is touching records only if the ancestry changed
def test_touch_option_enabled_doesnt_propagate_without_modification
AncestryTestDatabase.with_model(
:extra_columns => {:updated_at => :datetime},
:touch => true
) do |model|
way_back = Time.new(1984)
recently = Time.now - 1.minute
parent = model.create!
child = model.create!(:parent => parent)
grandchild = model.create!(:parent => child)
node1 = model.create!
node11 = node1.children.create!
node111 = node11.children.create!
# creating children update all the fields. this clears them back
model.update_all(updated_at: way_back)
grandchild.save
assert_equal way_back, grandchild.reload.updated_at, "main record updated_at timestamp was touched"
assert_equal way_back, child.reload.updated_at, "parent record was touched"
assert_equal way_back, parent.reload.updated_at, "grandparent record was touched"
node111.save!
assert node111.reload.updated_at < recently, "main record updated_at timestamp was touched"
assert node11.reload.updated_at < recently, "parent record was touched"
assert node1.reload.updated_at < recently, "grandparent record was touched"
end
end
@ -70,15 +103,14 @@ class TouchingTest < ActiveSupport::TestCase
:extra_columns => {:updated_at => :datetime},
:touch => true
) do |model|
way_back = Time.new(1984)
recently = Time.now - 1.minute
parent_1 = model.create!(:updated_at => way_back)
child_1_1 = model.create!(:updated_at => way_back, :parent => parent_1)
child_1_2 = model.create!(:updated_at => way_back, :parent => parent_1)
grandchild_1_1_1 = model.create!(:updated_at => way_back, :parent => child_1_1)
parent_1 = model.create!
child_1_1 = model.create!(:parent => parent_1)
child_1_2 = model.create!(:parent => parent_1)
grandchild_1_1_1 = model.create!(:parent => child_1_1)
model.update_all(:updated_at => way_back)
grandchild_1_1_1.children.create!
assert_equal way_back, child_1_2.reload.updated_at, "unrelated record was touched"

View File

@ -1,130 +1,344 @@
# frozen_string_literal: true
require_relative '../environment'
# this is testing attribute getters
class TreeNavigationTest < ActiveSupport::TestCase
def test_tree_navigation
AncestryTestDatabase.with_model :depth => 3, :width => 3 do |model, roots|
roots.each do |lvl0_node, lvl0_children|
# Ancestors assertions
assert_equal [], lvl0_node.ancestor_ids
assert_equal [], lvl0_node.ancestors
assert_equal [lvl0_node.id], lvl0_node.path_ids
assert_equal [lvl0_node], lvl0_node.path
assert_equal 0, lvl0_node.depth
# Parent assertions
assert_nil lvl0_node.parent_id
assert_nil lvl0_node.parent
refute lvl0_node.parent_id?
# Root assertions
assert_equal lvl0_node.id, lvl0_node.root_id
assert_equal lvl0_node, lvl0_node.root
assert lvl0_node.is_root?
# Children assertions
assert_equal lvl0_children.map(&:first).map(&:id), lvl0_node.child_ids
assert_equal lvl0_children.map(&:first), lvl0_node.children
assert lvl0_node.has_children?
assert !lvl0_node.is_childless?
# Siblings assertions
assert_equal roots.map(&:first).map(&:id), lvl0_node.sibling_ids
assert_equal roots.map(&:first), lvl0_node.siblings
assert lvl0_node.has_siblings?
assert !lvl0_node.is_only_child?
# Descendants assertions
descendants = model.all.find_all do |node|
node.ancestor_ids.include? lvl0_node.id
end
assert_equal descendants.map(&:id), lvl0_node.descendant_ids
assert_equal descendants, lvl0_node.descendants
# Subtree assertions
subtree = [lvl0_node] + descendants
assert_equal subtree.map(&:id), lvl0_node.subtree_ids
assert_equal subtree, lvl0_node.subtree
# Indirects assertions
indirects = lvl0_node.descendants - lvl0_node.children
assert_equal indirects.map(&:id), lvl0_node.indirect_ids
assert_equal indirects, lvl0_node.indirects
# up: |parent |root |ancestors|
# down: |children|descendants|indirects|
# across: |siblings|subtree |path |
ATTRIBUTE_MATRIX = {
root: {attribute_id: :root_id},
parent: {attribute_id: :parent_id, exists: :has_parent?, db: true},
ancestors: {attribute_ids: :ancestor_ids, exists: :ancestors?, db: true},
children: {attribute_ids: :child_ids, exists: :children?},
descendants: {attribute_ids: :descendant_ids},
indirects: {attribute_ids: :indirect_ids},
siblings: {attribute_ids: :sibling_ids, exists: :siblings?},
subtree: {attribute_ids: :subtree_ids},
path: {attribute_ids: :path_ids, db: true}
}.freeze
# NOTE: has_ancestors? is an alias for parent? / ancestors? but not tested
lvl0_children.each do |lvl1_node, lvl1_children|
# Ancestors assertions
assert_equal [lvl0_node.id], lvl1_node.ancestor_ids
assert_equal [lvl0_node], lvl1_node.ancestors
assert_equal [lvl0_node.id, lvl1_node.id], lvl1_node.path_ids
assert_equal [lvl0_node, lvl1_node], lvl1_node.path
assert_equal 1, lvl1_node.depth
# Parent assertions
assert_equal lvl0_node.id, lvl1_node.parent_id
assert_equal lvl0_node, lvl1_node.parent
assert lvl1_node.parent_id?
# Root assertions
assert_equal lvl0_node.id, lvl1_node.root_id
assert_equal lvl0_node, lvl1_node.root
assert !lvl1_node.is_root?
# Children assertions
assert_equal lvl1_children.map(&:first).map(&:id), lvl1_node.child_ids
assert_equal lvl1_children.map(&:first), lvl1_node.children
assert lvl1_node.has_children?
assert !lvl1_node.is_childless?
# Siblings assertions
assert_equal lvl0_children.map(&:first).map(&:id), lvl1_node.sibling_ids
assert_equal lvl0_children.map(&:first), lvl1_node.siblings
assert lvl1_node.has_siblings?
assert !lvl1_node.is_only_child?
# Descendants assertions
descendants = model.all.find_all do |node|
node.ancestor_ids.include? lvl1_node.id
end
assert_equal descendants.map(&:id), lvl1_node.descendant_ids
assert_equal descendants, lvl1_node.descendants
# Subtree assertions
subtree = [lvl1_node] + descendants
assert_equal subtree.map(&:id), lvl1_node.subtree_ids
assert_equal subtree, lvl1_node.subtree
# Indirects assertions
indirects = lvl1_node.descendants - lvl1_node.children
assert_equal indirects.map(&:id), lvl1_node.indirect_ids
assert_equal indirects, lvl1_node.indirects
# class level getters are in test/concerns/scopes_test.rb
# depth tests are in test/concerns/depth_constraints_tests.rb
def test_node_getters
AncestryTestDatabase.with_model do |model|
node1 = model.create!
node11 = model.create!(:parent => node1)
node111 = model.create!(:parent => node11)
node12 = model.create!(:parent => node1)
node2 = model.create!
node21 = model.create!(:parent => node2)
lvl1_children.each do |lvl2_node, lvl2_children|
# Ancestors assertions
assert_equal [lvl0_node.id, lvl1_node.id], lvl2_node.ancestor_ids
assert_equal [lvl0_node, lvl1_node], lvl2_node.ancestors
assert_equal [lvl0_node.id, lvl1_node.id, lvl2_node.id], lvl2_node.path_ids
assert_equal [lvl0_node, lvl1_node, lvl2_node], lvl2_node.path
assert_equal 2, lvl2_node.depth
# Parent assertions
assert_equal lvl1_node.id, lvl2_node.parent_id
assert_equal lvl1_node, lvl2_node.parent
assert lvl2_node.parent_id?
# Root assertions
assert_equal lvl0_node.id, lvl2_node.root_id
assert_equal lvl0_node, lvl2_node.root
assert !lvl2_node.is_root?
# Children assertions
assert_equal [], lvl2_node.child_ids
assert_equal [], lvl2_node.children
assert !lvl2_node.has_children?
assert lvl2_node.is_childless?
# Siblings assertions
assert_equal lvl1_children.map(&:first).map(&:id), lvl2_node.sibling_ids
assert_equal lvl1_children.map(&:first), lvl2_node.siblings
assert lvl2_node.has_siblings?
assert !lvl2_node.is_only_child?
# Descendants assertions
descendants = model.all.find_all do |node|
node.ancestor_ids.include? lvl2_node.id
end
assert_equal descendants.map(&:id), lvl2_node.descendant_ids
assert_equal descendants, lvl2_node.descendants
# Subtree assertions
subtree = [lvl2_node] + descendants
assert_equal subtree.map(&:id), lvl2_node.subtree_ids
assert_equal subtree, lvl2_node.subtree
# Indirects assertions
indirects = lvl2_node.descendants - lvl2_node.children
assert_equal indirects.map(&:id), lvl2_node.indirect_ids
assert_equal indirects, lvl2_node.indirects
# root: node1
assert_attribute node1, :parent, nil
assert_attribute node1, :root, node1
assert_attributes node1, :ancestors, []
assert_attributes node1, :children, [node11, node12]
assert_attributes node1, :descendants, [node11, node111, node12]
assert_attributes node1, :indirects, [node111]
assert_attributes node1, :siblings, [node1, node2]
assert_attributes node1, :subtree, [node1, node11, node111, node12]
assert_attributes node1, :path, [node1]
assert_equal(0, node1.depth)
assert node1.root?
# root: node11
assert_attribute node11, :parent, node1
assert_attribute node11, :root, node1
assert_attributes node11, :ancestors, [node1]
assert_attributes node11, :children, [node111]
assert_attributes node11, :descendants, [node111]
assert_attributes node11, :indirects, []
assert_attributes node11, :siblings, [node11, node12]
assert_attributes node11, :subtree, [node11, node111]
assert_attributes node11, :path, [node1, node11]
assert_equal(1, node11.depth)
refute node11.root?
# root: node111
assert_attribute node111, :parent, node11
assert_attribute node111, :root, node1
assert_attributes node111, :ancestors, [node1, node11]
assert_attributes node111, :children, []
assert_attributes node111, :descendants, []
assert_attributes node111, :indirects, []
assert_attributes node111, :siblings, [node111], exists: false
assert_attributes node111, :subtree, [node111]
assert_attributes node111, :path, [node1, node11, node111]
assert_equal(2, node111.depth)
refute node111.root?
# root: node12
assert_attribute node12, :parent, node1
assert_attribute node12, :root, node1
assert_attributes node12, :ancestors, [node1]
assert_attributes node12, :children, []
assert_attributes node12, :descendants, []
assert_attributes node12, :indirects, []
assert_attributes node12, :siblings, [node11, node12]
assert_attributes node12, :subtree, [node12]
assert_attributes node12, :path, [node1, node12]
assert_equal(1, node12.depth)
refute node12.root?
# root: node2
assert_attribute node2, :parent, nil
assert_attribute node2, :root, node2
assert_attributes node2, :ancestors, []
assert_attributes node2, :children, [node21]
assert_attributes node2, :descendants, [node21]
assert_attributes node2, :indirects, []
assert_attributes node2, :siblings, [node1, node2]
assert_attributes node2, :subtree, [node2, node21]
assert_attributes node2, :path, [node2]
assert_equal(0, node2.depth)
assert node2.root?
# root: node21
assert_attribute node21, :parent, node2
assert_attribute node21, :root, node2
assert_attributes node21, :ancestors, [node2]
assert_attributes node21, :children, []
assert_attributes node21, :descendants, []
assert_attributes node21, :indirects, []
assert_attributes node21, :siblings, [node21], exists: false
assert_attributes node21, :subtree, [node21]
assert_attributes node21, :path, [node2, node21]
assert_equal(1, node21.depth)
refute node21.root?
end
end
def test_node_in_db_first_node
AncestryTestDatabase.with_model do |model|
root = model.create!
node = model.new
# new / not saved
assert_attributes node, :ancestors, []
# assert_attributes([nil], node, :path) # not valid yet
assert_attribute node, :parent, nil
# saved
node.save!
assert_attributes node, :ancestors, []
assert_attributes node, :path, [node]
assert_attribute node, :parent, nil
# changed / not saved
node.ancestor_ids = [root.id]
assert_attributes node, :ancestors, [root], db: []
assert_attributes node, :path, [root, node], db: [node]
assert_attribute node, :parent, root, db: nil
# changed / saved
node.save!
node = model.find(node.id)
assert_attributes node, :ancestors, [root]
assert_attributes node, :path, [root, node]
assert_attribute node, :parent, root
# reloaded
node = model.find(node.id)
assert_attributes node, :ancestors, [root]
assert_attributes node, :path, [root, node]
assert_attribute node, :parent, root
end
end
# kinda same as last test, more concerned with children
def test_node_in_database_children
AncestryTestDatabase.with_model do |model|
node1 = model.create!
node11 = node1.children.create!
node111 = node11.children.create!
node2 = model.create!
# parent
assert_attributes node1, :ancestors, []
assert_attributes node1, :children, [node11]
assert_attributes node1, :descendants, [node11, node111]
assert_attributes node1, :indirects, [node111]
# non-parent
assert_attributes node2, :ancestors, []
assert_attributes node2, :children, []
assert_attributes node2, :descendants, []
assert_attributes node2, :indirects, []
# node
assert_attributes node11, :ancestors, [node1]
assert_attributes node11, :children, [node111]
assert_attributes node11, :descendants, [node111]
# changed (not saved)
node11.parent = node2
# reloads?
# old parent (not saved)
assert_attributes node1, :ancestors, []
assert_attributes node1, :children, [node11]
assert_attributes node1, :descendants, [node11, node111]
assert_attributes node1, :indirects, [node111]
# new parent (not saved)
assert_attributes node2, :ancestors, []
assert_attributes node2, :children, []
assert_attributes node2, :descendants, []
assert_attributes node2, :indirects, []
# node (not saved)
assert_attributes node11, :ancestors, [node2], db: [node1]
assert_attributes node11, :children, [node111]
assert_attributes node11, :descendants, [node111]
# in database (again - but in a different hierarchy)
node11.save!
node1.reload
node2.reload
# are these necessary?
# do we want this to work without?
# old parent (saved)
assert_attributes node1, :ancestors, []
assert_attributes node1, :children, []
assert_attributes node1, :descendants, []
assert_attributes node1, :indirects, []
# new parent (saved)
assert_attributes node2, :ancestors, []
assert_attributes node2, :children, [node11]
assert_attributes node2, :descendants, [node11, node111]
assert_attributes node2, :indirects, [node111]
# node (saved)
assert_attributes node11, :ancestors, [node2]
assert_attributes node11, :children, [node111]
assert_attributes node11, :descendants, [node111]
end
end
# didn't know best way to test before_save values were correct.
# hardcoding ids will break non-int id tests
# please create PR or issue if you have a better idea
def test_node_before_last_save
AncestryTestDatabase.with_model do |model|
assert true, "this runs for integer primary keys"
skip "only written for integer keys" unless model.primary_key_is_an_integer?
model.delete_all
node1 = model.create!(:id => 1)
node11 = node1.children.create!(:id => 2)
node111 = node11.children.create!(:id => 3)
node111.children.create!(:id => 4)
node11.children.create!(:id => 5)
node2 = model.create!(:id => 6)
# loosing context in class_eval
# basically rewriting minit-test.
model.class_eval do
def update_descendants_with_new_ancestry
# only the top most node (node2 for us)
# should be updating the ancestry for dependents
if ancestry_callbacks_disabled?
raise "callback disabled for #{id}" if id == 2
else
raise "callback eabled for #{id}" if id != 2
# want to make sure we're pointing at the correct nodes
actual = unscoped_descendants_before_last_save.order(:id).map(&:id)
raise "unscoped_descendants_before_last_save was #{actual}" unless actual == [3, 4, 5]
actual = path_ids_before_last_save
raise "bad path_ids(before) is #{actual}" unless actual == [1, 2]
actual = path_ids
raise "bad path_ids is #{actual}" unless actual == [6, 2]
actual = parent_id_before_last_save
raise "bad parent_id(before) id #{actual}" unless actual == 1
actual = parent_id
raise "bad parent_id(before) id #{actual}" unless actual == 6
actual = ancestor_ids_before_last_save
raise "bad ancestor_ids(before) id #{actual}" unless actual == [1]
actual = ancestor_ids
raise "bad ancestor_ids(before) id #{actual}" unless actual == [6]
end
super
end
end
node11.update(:parent => node2)
end
end
private
def assert_attribute(node, attribute_name, value, db: :value, exists: :value)
attribute_id = ATTRIBUTE_MATRIX[attribute_name][:attribute_id]
if value.nil?
assert_nil node.send(attribute_name)
assert_nil node.send(attribute_id)
else
assert_equal value, node.send(attribute_name)
assert_equal value.id, node.send(attribute_id)
end
if ATTRIBUTE_MATRIX[attribute_name][:db]
attribute_db_name = "#{attribute_id}_in_database"
db = value if db == :value
if db.nil?
assert_nil node.send(attribute_db_name)
else
assert_equal db.id, node.send(attribute_db_name)
end
end
exists_name = ATTRIBUTE_MATRIX[attribute_name][:exists] or return
exists = value.present? if exists == :value
if exists
assert node.send(exists_name)
else
refute node.send(exists_name)
end
end
# this is a short form for assert_eaual
# It tests the attribute, attribute_ids, and the attribute? method
# the singular vs plural form is not consistent and so attribute_ids is needed in many cases
#
# when testing db tests (attribute_in_database) only the attribute_ids is tested
# so attribute_name = false is passed in
#
# @param value [Array] expected output
# @param attribute_name [Symbol] attribute to test
# @param exists [true|false] test the exists "attribute? (default values.present?)
# @param db [Array[AR]] value that should be reflected _in_database (default: use values)
# skips if not supported in matrix
def assert_attributes(node, attribute_name, values, db: :values, exists: :values)
attribute_ids = ATTRIBUTE_MATRIX[attribute_name][:attribute_ids]
assert_equal values.map(&:id), node.send(attribute_name).order(:id).map(&:id)
assert_equal values.map(&:id), node.send(attribute_ids).sort
if ATTRIBUTE_MATRIX[attribute_name][:db]
db = values if db == :values
attribute_db_name = "#{attribute_ids}_in_database"
assert_equal db.map(&:id), node.send(attribute_db_name).sort
end
exists_name = ATTRIBUTE_MATRIX[attribute_name][:exists] or return
exists = values.present? if exists == :values
if exists
assert node.send(exists_name)
else
refute node.send(exists_name)
end
end
end

View File

@ -1,8 +1,10 @@
# frozen_string_literal: true
require_relative '../environment'
class TreePredicateTest < ActiveSupport::TestCase
def test_tree_predicates
AncestryTestDatabase.with_model :depth => 2, :width => 3 do |model, roots|
AncestryTestDatabase.with_model :depth => 2, :width => 3 do |_model, roots|
roots.each do |lvl0_node, lvl0_children|
root, children = lvl0_node, lvl0_children.map(&:first)
# Ancestors assertions
@ -19,7 +21,7 @@ class TreePredicateTest < ActiveSupport::TestCase
# Children assertions
assert root.has_children?
assert !root.is_childless?
assert children.map { |n| n.is_childless? }.all?
assert children.map(&:is_childless?).all?
assert children.map { |n| !root.child_of?(n) }.all?
assert children.map { |n| n.child_of?(root) }.all?
# Siblings assertions
@ -31,6 +33,10 @@ class TreePredicateTest < ActiveSupport::TestCase
# Descendants assertions
assert children.map { |n| !root.descendant_of?(n) }.all?
assert children.map { |n| n.descendant_of?(root) }.all?
# Subtrees assertions
assert children.map { |n| n.in_subtree_of?(root) }.all?
assert children.map { |n| !root.in_subtree_of?(n) }.all?
assert root.in_subtree_of?(root)
end
end
end

View File

@ -1,17 +0,0 @@
require_relative '../environment'
class UpdateTest < ActiveSupport::TestCase
def test_node_creation_in_after_commit
AncestryTestDatabase.with_model do |model|
children=[]
model.instance_eval do
attr_accessor :idx
self.after_commit do
children << self.children.create!(:idx => self.idx - 1) if self.idx > 0
end
end
model.create!(:idx => 3)
assert_equal [1,2,3], children.first.ancestor_ids
end
end
end

View File

@ -1,42 +0,0 @@
require_relative '../environment'
class ValidationsTest < ActiveSupport::TestCase
def test_ancestry_column_validation
AncestryTestDatabase.with_model do |model|
node = model.create
['3', '10/2', '1/4/30', nil].each do |value|
node.send :write_attribute, model.ancestry_column, value
node.valid?; assert node.errors[model.ancestry_column].blank?
end
['a', 'a/b', '-34'].each do |value|
node.send :write_attribute, model.ancestry_column, value
node.valid?; assert !node.errors[model.ancestry_column].blank?
end
end
end
def test_ancestry_column_validation_alt
AncestryTestDatabase.with_model(:id => :string, :primary_key_format => /[a-z]/) do |model|
node = model.create(:id => 'z')
['a', 'a/b', 'a/b/c', nil].each do |value|
node.send :write_attribute, model.ancestry_column, value
node.valid?; assert node.errors[model.ancestry_column].blank?
end
['1', '1/2', 'a-b/c'].each do |value|
node.send :write_attribute, model.ancestry_column, value
node.valid?; assert !node.errors[model.ancestry_column].blank?
end
end
end
def test_ancestry_validation_exclude_self
AncestryTestDatabase.with_model do |model|
parent = model.create!
child = parent.children.create!
assert_raise ActiveRecord::RecordInvalid do
parent.update! :parent => child
end
end
end
end

View File

@ -5,15 +5,18 @@ sqlite3:
pg:
adapter: postgresql
database: ancestry_test
host: 127.0.0.1
port: 5432
username: postgres
password: password
min_messages: WARNING
mysql:
adapter: mysql
database: ancestry_test
username: travis
encoding: utf8
mysql2:
mysql2: &mysql
adapter: mysql2
database: ancestry_test
username: travis
host: 127.0.0.1
port: 3306
username: root
password: password
encoding: utf8
mysql:
<<: *mysql

View File

@ -1,35 +1,24 @@
# frozen_string_literal: true
require 'rubygems'
require 'bundler/setup'
require 'simplecov'
require 'coveralls'
SimpleCov.formatter = Coveralls::SimpleCov::Formatter
SimpleCov.start do
add_filter '/test/'
add_filter '/vendor/'
if ENV["COVERAGE"]
require 'simplecov'
SimpleCov.start do
add_filter '/test/'
add_filter '/vendor/'
end
end
require 'active_support'
require 'active_support/test_case'
require 'test_helpers'
ActiveSupport.test_order = :random if ActiveSupport.respond_to?(:test_order=)
ActiveSupport::TestCase.include(TestHelpers)
require 'active_record'
require 'logger'
# Rails 3.2 has issues with mysql 5.7, primary key not being null.
# See https://stackoverflow.com/questions/33742967
if ActiveRecord::VERSION::MAJOR < 4
begin
require 'active_record/connection_adapters/mysql_adapter'
ActiveRecord::ConnectionAdapters::MysqlAdapter
class ActiveRecord::ConnectionAdapters::MysqlAdapter
NATIVE_DATABASE_TYPES[:primary_key] = "int(11) auto_increment PRIMARY KEY"
end
rescue LoadError
# not running with mysql, don't monkey patch
end
end
# Make absolutely sure we are testing local ancestry
require File.expand_path('../../lib/ancestry', __FILE__)
@ -37,61 +26,134 @@ class AncestryTestDatabase
def self.setup
# Silence I18n and Activerecord logging
I18n.enforce_available_locales = false if I18n.respond_to? :enforce_available_locales=
ActiveRecord::Base.logger = Logger.new(STDERR)
ActiveRecord::Base.logger = Logger.new($stderr)
ActiveRecord::Base.logger.level = Logger::Severity::UNKNOWN
# Assume Travis CI database config if no custom one exists
filename = if File.exist?(File.expand_path('../database.yml', __FILE__))
File.expand_path('../database.yml', __FILE__)
else
File.expand_path('../database.ci.yml', __FILE__)
end
File.expand_path('../database.yml', __FILE__)
else
File.expand_path('../database.ci.yml', __FILE__)
end
# Setup database connection
config = YAML.load_file(filename)[db_type]
ActiveRecord::Base.establish_connection config
all_config =
if YAML.respond_to?(:safe_load_file)
YAML.safe_load_file(filename, aliases: true)
else
YAML.load_file(filename)
end
config = all_config[db_type]
if config.blank?
$stderr.puts "", "", "ERROR: Could not find '#{db_type}' in #{filename}"
$stderr.puts "Pick from: #{all_config.keys.join(", ")}", "", ""
exit(1)
end
if ActiveRecord::VERSION::MAJOR >= 6
ActiveRecord::Base.establish_connection(**config)
else
ActiveRecord::Base.establish_connection config
end
begin
ActiveRecord::Base.connection
Ancestry.default_update_strategy = ENV["UPDATE_STRATEGY"] == "sql" ? :sql : :ruby
Ancestry.default_ancestry_format = ENV["FORMAT"].to_sym if ENV["FORMAT"].present?
# This only affects postgres
# the :ruby code path will get tested in mysql and sqlite3
Ancestry.default_update_strategy = :sql if db_type == "pg"
rescue => err
puts "testing #{db_type} #{Ancestry.default_update_strategy == :sql ? "(sql) " : ""}(with #{column_type} #{ancestry_column})"
puts "column format: #{Ancestry.default_ancestry_format} options: #{column_options.inspect}"
rescue StandardError => e
if ENV["CI"]
raise
else
puts "\nSkipping tests for '#{db_type}'"
puts " #{err}\n\n"
puts " #{e}\n\n"
exit 0
end
end
end
def self.with_model options = {}
def self.column_type
@column_type ||= ENV["ANCESTRY_COLUMN_TYPE"].presence || "string"
end
def self.ancestry_column
@ancestry_column ||= ENV["ANCESTRY_COLUMN"].presence || "ancestry"
end
def self.ancestry_collation
return @ancestry_collation if defined?(@ancestry_collation)
env = ENV["ANCESTRY_LOCALE"].presence
@ancestry_collation =
if env
env
elsif postgres?
"C"
elsif db_type =~ /mysql/i
"utf8mb4_bin"
else
"binary"
end
end
# @param force_allow_nil [Boolean] true if we want to allow nulls
# used when we are testing migrating to ancestry
def self.column_options(force_allow_nil: false)
@column_options ||=
if column_type == "string"
{
:collation => ancestry_collation == "default" ? nil : ancestry_collation,
:null => !materialized_path2?
}
else
{
:limit => 3000,
:null => !materialized_path2?
}
end
force_allow_nil ? @column_options.merge(:null => true) : @column_options
end
def self.with_model(options = {})
depth = options.delete(:depth) || 0
width = options.delete(:width) || 0
skip_ancestry = options.delete(:skip_ancestry)
extra_columns = options.delete(:extra_columns)
default_scope_params = options.delete(:default_scope_params)
table_options={}
options[:ancestry_column] ||= ancestry_column
table_options = {}
table_options[:id] = options.delete(:id) if options.key?(:id)
ActiveRecord::Base.connection.create_table 'test_nodes', table_options do |table|
table.string options[:ancestry_column] || :ancestry
table.integer options[:depth_cache_column] || :ancestry_depth if options[:cache_depth]
ActiveRecord::Base.connection.create_table 'test_nodes', **table_options do |table|
table.send(column_type, options[:ancestry_column], **column_options(force_allow_nil: skip_ancestry))
case options[:cache_depth]
when true
table.integer :ancestry_depth
when :virtual
# sorry, this duplicates has_ancestry a little
path_module = Ancestry::HasAncestry.ancestry_format_module(options[:ancestry_format])
ancestry_depth_sql = path_module.construct_depth_sql("test_nodes", options[:ancestry_column], '/')
table.virtual :ancestry_depth, type: :integer, as: ancestry_depth_sql, stored: true
when nil, false
# no column
else
table.integer options[:cache_depth]
end
if options[:counter_cache]
counter_cache_column = options[:counter_cache] == true ? :children_count : options[:counter_cache]
table.integer counter_cache_column
table.integer counter_cache_column, default: 0, null: false
end
extra_columns.each do |name, type|
extra_columns&.each do |name, type|
table.send type, name
end unless extra_columns.nil?
end
end
testmethod = caller[0][/`.*'/][1..-2]
model_name = testmethod.camelize + "TestNode"
model_name = "#{testmethod.camelize}TestNode"
begin
model = Class.new(ActiveRecord::Base)
@ -103,7 +165,7 @@ class AncestryTestDatabase
model.send :default_scope, lambda { model.where(default_scope_params) }
end
model.has_ancestry options unless options.delete(:skip_ancestry)
model.has_ancestry options unless skip_ancestry
if depth > 0
yield model, create_test_nodes(model, depth, width)
@ -117,16 +179,26 @@ class AncestryTestDatabase
end
end
def self.create_test_nodes model, depth, width, parent = nil
unless depth == 0
def self.create_test_nodes(model, depth, width, parent = nil)
if depth == 0
[]
else
Array.new width do
node = model.create!(:parent => parent)
[node, create_test_nodes(model, depth - 1, width, node)]
end
else; []; end
end
end
private
def self.postgres?
db_type == "pg"
end
def self.materialized_path2?
return @materialized_path2 if defined?(@materialized_path2)
@materialized_path2 = (ENV["FORMAT"] == "materialized_path2")
end
def self.db_type
ENV["DB"].presence || "sqlite3"
@ -140,4 +212,4 @@ puts " Ruby: #{RUBY_VERSION}"
puts " ActiveRecord: #{ActiveRecord::VERSION::STRING}"
puts " Database: #{ActiveRecord::Base.connection.adapter_name}\n\n"
require 'minitest/autorun' if ActiveSupport::VERSION::STRING > "4"
require 'minitest/autorun'

25
test/test_helpers.rb Normal file
View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module TestHelpers
def assert_ancestry(node, value, child: :skip, db: :value)
column_name = node.class.ancestry_column
if value.nil?
assert_nil node.send(column_name)
else
assert_equal value, node.send(column_name)
end
db = value if db == :value
if db.nil?
assert_nil node.send("#{column_name}_in_database")
else
assert_equal db, node.send("#{column_name}_in_database")
end
if child.nil?
assert_nil node.child_ancestry
elsif child != :skip
assert_equal child, node.child_ancestry
end
end
end