Compare commits
286 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
2f6e6a635b | |
|
|
4f1593ca2e | |
|
|
e8e0bb1ebb | |
|
|
9ea121d125 | |
|
|
4d267887f4 | |
|
|
5bcd89c061 | |
|
|
9ed9d59078 | |
|
|
6cc93989c0 | |
|
|
98d831b865 | |
|
|
333714cc27 | |
|
|
b4f288a7e3 | |
|
|
cddecd1e42 | |
|
|
5e65618739 | |
|
|
6a3b3fb3a5 | |
|
|
faf9029af1 | |
|
|
78d63a8e10 | |
|
|
541c167938 | |
|
|
4dda866e0b | |
|
|
fe8c2ce207 | |
|
|
7176d19715 | |
|
|
8a4e7c0499 | |
|
|
5129762631 | |
|
|
404c5141e7 | |
|
|
07336a7a65 | |
|
|
d613f00c2c | |
|
|
80abe3c566 | |
|
|
e1c0f355a5 | |
|
|
b594e99ac0 | |
|
|
498638e6f1 | |
|
|
8cb59e6aed | |
|
|
d2e2f2a7fa | |
|
|
2e7a5304e0 | |
|
|
c2df44cc8b | |
|
|
9e6f653ba6 | |
|
|
9dab81f508 | |
|
|
1613ac2463 | |
|
|
99620d8675 | |
|
|
a61da6c4e0 | |
|
|
af0d66dd25 | |
|
|
25dd26e8a8 | |
|
|
c7f49dddfd | |
|
|
f1b8d95834 | |
|
|
5c637482f3 | |
|
|
08539c5ac5 | |
|
|
c08b07dcb0 | |
|
|
753013a1bb | |
|
|
cb9635cc3a | |
|
|
22c199152d | |
|
|
3e25711617 | |
|
|
d196dd4b80 | |
|
|
49484a383a | |
|
|
8bd197f04a | |
|
|
3fa8d97a12 | |
|
|
0e17fe5efd | |
|
|
ac5ef56dd2 | |
|
|
6d0318e30e | |
|
|
ef5be9d33d | |
|
|
a84b0a3403 | |
|
|
c0db14d3a7 | |
|
|
dbd23f2791 | |
|
|
d7b577a2c6 | |
|
|
31bcc1357f | |
|
|
1f441c2e66 | |
|
|
8f06902839 | |
|
|
c4832d885a | |
|
|
fd0b86d215 | |
|
|
92d9c6833b | |
|
|
1d310894cd | |
|
|
eb3f6217d0 | |
|
|
e9a6b85ed4 | |
|
|
257728b342 | |
|
|
009aafc4c5 | |
|
|
be23abb704 | |
|
|
963c1eaba3 | |
|
|
54dcb36540 | |
|
|
fe20579f56 | |
|
|
0000a3cbcc | |
|
|
ed20bfa6c3 | |
|
|
de55f99c3a | |
|
|
304fb8f3ed | |
|
|
10866f19a8 | |
|
|
6da57931be | |
|
|
3dcb13d568 | |
|
|
a3f39eab3d | |
|
|
903b9dde38 | |
|
|
3e1d33444d | |
|
|
907f598b87 | |
|
|
f7810d5424 | |
|
|
d4be960589 | |
|
|
7a8b04ee5c | |
|
|
dbb09c2453 | |
|
|
5a4e0dd64a | |
|
|
3401e0a32d | |
|
|
659952451c | |
|
|
6c2b052908 | |
|
|
2b211d88c9 | |
|
|
3cc9c2ccc7 | |
|
|
5b839d9fe1 | |
|
|
dbbd720856 | |
|
|
c9fdd41c18 | |
|
|
247e745e2e | |
|
|
015ef1eeca | |
|
|
9b3a034661 | |
|
|
d00654a4e8 | |
|
|
6f3326e960 | |
|
|
0bba8f6fb7 | |
|
|
df7aeb31c4 | |
|
|
e76cba3a2f | |
|
|
b559d8b583 | |
|
|
8f7ef4fecd | |
|
|
43ceac4c17 | |
|
|
290f8a120b | |
|
|
19f8434334 | |
|
|
11fa95aa2a | |
|
|
e246bee2d0 | |
|
|
07c74caab9 | |
|
|
91637e0b12 | |
|
|
741199aee8 | |
|
|
257af8a0bc | |
|
|
a39494657e | |
|
|
0c0240aefe | |
|
|
918e443d94 | |
|
|
f04349b665 | |
|
|
7621f09363 | |
|
|
beeeb4e275 | |
|
|
a5b2d27aa5 | |
|
|
0ba9506372 | |
|
|
bf947ebb09 | |
|
|
3879d3d522 | |
|
|
cfa09bdc61 | |
|
|
6e25859603 | |
|
|
8b71773e8b | |
|
|
ff8ee39f7a | |
|
|
187dc1d948 | |
|
|
1a2535567c | |
|
|
796ef7c320 | |
|
|
ea0bc1e3f4 | |
|
|
04fcb08baa | |
|
|
66a20f2a3b | |
|
|
92b68e63be | |
|
|
aeab49dc4a | |
|
|
b5f22921fd | |
|
|
a078aa2daf | |
|
|
f6213602da | |
|
|
719a457f2c | |
|
|
8414d90832 | |
|
|
9ed56654aa | |
|
|
22a3f18797 | |
|
|
6a29b385f9 | |
|
|
cb7f3032b5 | |
|
|
5c047e2398 | |
|
|
ede43a2ad5 | |
|
|
18ad23e1aa | |
|
|
d19d0eb741 | |
|
|
7ec1a51c98 | |
|
|
e222b5d788 | |
|
|
53f191488c | |
|
|
0fe59214a8 | |
|
|
559fce9ba9 | |
|
|
51d856fa51 | |
|
|
bdb9143afd | |
|
|
ced8a6fe9c | |
|
|
e7459e920c | |
|
|
f66c906ed8 | |
|
|
73468ba1da | |
|
|
ac788217cf | |
|
|
8a46ea1aa0 | |
|
|
c3b0540a54 | |
|
|
f3460357ee | |
|
|
a70919d11b | |
|
|
093855986f | |
|
|
90fb1a1ba2 | |
|
|
00647bcdda | |
|
|
0fcd12fd36 | |
|
|
aabf5d2112 | |
|
|
0b6a736b58 | |
|
|
68c329e7df | |
|
|
fc83408080 | |
|
|
d161895fdc | |
|
|
fabe8c3436 | |
|
|
a81c30cee9 | |
|
|
103eb47230 | |
|
|
1441f9d73d | |
|
|
7534861936 | |
|
|
1db4278f6b | |
|
|
88c4676348 | |
|
|
ae24dade82 | |
|
|
9a4a444705 | |
|
|
741d0456da | |
|
|
b4bac83a63 | |
|
|
6a6d3e7ec6 | |
|
|
1754f39c1e | |
|
|
a7ccf0260e | |
|
|
946f20d70f | |
|
|
c82291ca1c | |
|
|
5555e384d8 | |
|
|
41a6603487 | |
|
|
e5788a8fef | |
|
|
40b8123ee3 | |
|
|
208343612b | |
|
|
cf6fe14030 | |
|
|
9c2a7e92ae | |
|
|
d33a11b78e | |
|
|
b55397ba4d | |
|
|
8d7dbdfa71 | |
|
|
13a2cec9bf | |
|
|
e99fb0760a | |
|
|
e9737dcc87 | |
|
|
72e1c224b6 | |
|
|
79abd3391f | |
|
|
b1f2f687f0 | |
|
|
7afdc1eb1c | |
|
|
38d8acbf56 | |
|
|
68dcb0bd55 | |
|
|
43e5c4c2fd | |
|
|
46957a5fa0 | |
|
|
be5e6d8ed5 | |
|
|
71c3c73566 | |
|
|
a85f44853d | |
|
|
1bbb0a9675 | |
|
|
766eb3db0c | |
|
|
86b84ceec0 | |
|
|
99cbcd3fe2 | |
|
|
317c3ba69e | |
|
|
a90c99075d | |
|
|
ef78cbed5c | |
|
|
716b2d2d25 | |
|
|
b70976f1f4 | |
|
|
17c73ba9cc | |
|
|
57a09f76e6 | |
|
|
c26c2ffbfa | |
|
|
4d413b22d9 | |
|
|
57bb1aa4f9 | |
|
|
1d02551337 | |
|
|
447f17f455 | |
|
|
460fdff9ba | |
|
|
b3eb72f7bb | |
|
|
97cdc5df13 | |
|
|
a1a2398f07 | |
|
|
5161639685 | |
|
|
e8f67eab41 | |
|
|
cd5dc589a6 | |
|
|
67bb06ef84 | |
|
|
d7aa7209ec | |
|
|
824e8e6464 | |
|
|
0a1337e41e | |
|
|
69f2324d09 | |
|
|
7f78f812ec | |
|
|
3171caf5e8 | |
|
|
54e7d64abc | |
|
|
2ff497e3d6 | |
|
|
463a91abe3 | |
|
|
c06c82908b | |
|
|
1f17b64b04 | |
|
|
890b9e1105 | |
|
|
f70a056de6 | |
|
|
a208f32ad2 | |
|
|
8ce5ee5f7e | |
|
|
5e7d71dcf7 | |
|
|
dfb4829e23 | |
|
|
71fe704279 | |
|
|
560770d9d2 | |
|
|
201e59bc75 | |
|
|
d39ca3b99f | |
|
|
4418715351 | |
|
|
421826b67c | |
|
|
10d386b7d5 | |
|
|
78011f72bd | |
|
|
290a391807 | |
|
|
efa912d080 | |
|
|
3a3a5bc728 | |
|
|
1fc26fb3ae | |
|
|
a6bf43683e | |
|
|
b8f241fb1e | |
|
|
0b9f9e1aad | |
|
|
93eef2a669 | |
|
|
ea36744e94 | |
|
|
e35a95c6eb | |
|
|
147a28d78e | |
|
|
cf7c8380a0 | |
|
|
8b53592db9 | |
|
|
8b8bb233d3 | |
|
|
d888c03ed6 | |
|
|
4db1a520b3 | |
|
|
006f1d1599 | |
|
|
331793043b |
|
|
@ -1 +0,0 @@
|
|||
service_name: travis-ci
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
|
@ -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
|
||||
|
|
@ -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/**/*" ]
|
||||
33
.travis.yml
|
|
@ -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
|
||||
33
Appraisals
|
|
@ -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
|
||||
|
|
|
|||
177
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
7
Gemfile
|
|
@ -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
|
|
@ -1,30 +1,61 @@
|
|||
[](https://travis-ci.org/stefankroes/ancestry) [](https://coveralls.io/r/stefankroes/ancestry) [](https://gitter.im/stefankroes/ancestry?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [](https://hakiri.io/github/stefankroes/ancestry/master)
|
||||
[](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
|
||||
|
|
|
|||
2
Rakefile
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/setup'
|
||||
require 'bundler/gem_tasks'
|
||||
require 'rake/testtask'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
@ -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
|
||||
|
|
@ -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: "../"
|
||||
|
|
@ -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: "../"
|
||||
|
|
@ -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: "../"
|
||||
|
|
|
|||
|
|
@ -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: "../"
|
||||
|
|
|
|||
|
|
@ -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: "../"
|
||||
|
|
@ -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: "../"
|
||||
|
|
@ -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: "../"
|
||||
|
|
@ -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: "../"
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 19 KiB |
BIN
img/children.png
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 19 KiB |
BIN
img/parent.png
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 19 KiB |
BIN
img/path.png
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 19 KiB |
BIN
img/root.png
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 18 KiB |
BIN
img/siblings.png
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 19 KiB |
BIN
img/subtree.png
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ancestry
|
||||
class AncestryException < RuntimeError
|
||||
end
|
||||
|
||||
class AncestryIntegrityException < AncestryException
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ancestry
|
||||
VERSION = "3.2.1"
|
||||
VERSION = '5.0.0'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../environment'
|
||||
|
||||
class IntegrityCheckingAndRestaurationTest < ActiveSupport::TestCase
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||