├── .github └── workflows │ ├── linter.yml │ └── tests.yml ├── .gitignore ├── .rspec ├── .standard.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── gemfiles ├── activerecord_52_pg_10.gemfile ├── activerecord_52_pg_11.gemfile ├── activerecord_52_pg_12.gemfile ├── activerecord_52_pg_13.gemfile ├── activerecord_52_pg_14.gemfile ├── activerecord_52_pg_15.gemfile ├── activerecord_60_pg_10.gemfile ├── activerecord_60_pg_11.gemfile ├── activerecord_60_pg_12.gemfile ├── activerecord_60_pg_13.gemfile ├── activerecord_60_pg_14.gemfile ├── activerecord_60_pg_15.gemfile ├── activerecord_61_pg_10.gemfile ├── activerecord_61_pg_11.gemfile ├── activerecord_61_pg_12.gemfile ├── activerecord_61_pg_13.gemfile ├── activerecord_61_pg_14.gemfile ├── activerecord_61_pg_15.gemfile ├── activerecord_70_pg_10.gemfile ├── activerecord_70_pg_11.gemfile ├── activerecord_70_pg_12.gemfile ├── activerecord_70_pg_13.gemfile ├── activerecord_70_pg_14.gemfile ├── activerecord_70_pg_15.gemfile ├── activerecord_71_pg_10.gemfile ├── activerecord_71_pg_11.gemfile ├── activerecord_71_pg_12.gemfile ├── activerecord_71_pg_13.gemfile ├── activerecord_71_pg_14.gemfile ├── activerecord_71_pg_15.gemfile ├── activerecord_72_pg_10.gemfile ├── activerecord_72_pg_11.gemfile ├── activerecord_72_pg_12.gemfile ├── activerecord_72_pg_13.gemfile ├── activerecord_72_pg_14.gemfile ├── activerecord_72_pg_15.gemfile ├── activerecord_80_pg_10.gemfile ├── activerecord_80_pg_11.gemfile ├── activerecord_80_pg_12.gemfile ├── activerecord_80_pg_13.gemfile ├── activerecord_80_pg_14.gemfile └── activerecord_80_pg_15.gemfile ├── lib ├── pg_ltree.rb └── pg_ltree │ ├── base.rb │ ├── callbacks.rb │ ├── model.rb │ └── version.rb ├── pg_ltree.gemspec └── spec ├── database.yml.sample ├── pg_ltree ├── base_spec.rb ├── callbacks_spec.rb └── model_spec.rb ├── spec_helper.rb └── support ├── database.rb ├── database_cleaner.rb └── schema.rb /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Ruby Linter 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | run: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 2.7 22 | bundler-cache: true 23 | 24 | - name: Run linter 25 | run: bundle exec rake standard 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Gem Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | run: 11 | services: 12 | postgres: 13 | image: postgres 14 | env: 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_DB: pg_ltree_test 17 | ports: 18 | - 5432:5432 19 | options: >- 20 | --health-cmd pg_isready 21 | --health-interval 10s 22 | --health-timeout 5s 23 | --health-retries 5 24 | 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | ruby: 29 | - '3.3' 30 | - '3.2' 31 | - '3.1' 32 | - '3.0' 33 | - '2.7' 34 | 35 | runs-on: ubuntu-latest 36 | 37 | env: 38 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 39 | 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - name: Set up Ruby 44 | uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{ matrix.ruby }} 47 | bundler-cache: true 48 | 49 | - name: Setup DB 50 | run: cp spec/database.yml.sample spec/database.yml 51 | 52 | - name: Install dependencies 53 | run: bundle exec appraisal install 54 | 55 | - name: Run tests 56 | run: bundle exec appraisal rake spec 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | .bundle 4 | doc 5 | **/*.log 6 | **/*.db 7 | .yardoc 8 | coverage 9 | database.yml 10 | *.lock 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | fix: false 2 | parallel: true 3 | format: progress 4 | ruby_version: 2.7.6 5 | default_ignores: false 6 | 7 | ignore: 8 | - 'vendor/**/*' 9 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | def version_to_label(version) 2 | version.scan(/\d/).join 3 | end 4 | 5 | def add_appraise_for(activerecord_version:, pg_version:) 6 | appraise "activerecord_#{version_to_label(activerecord_version)}_pg_#{version_to_label(pg_version)}" do 7 | gem "activerecord", activerecord_version, require: "active_record" 8 | gem "pg", pg_version 9 | end 10 | end 11 | 12 | SUPPORTED_PG_VERSIONS = ["~> 1.0", "~> 1.1", "~> 1.2", "~> 1.3", "~> 1.4", "~> 1.5"] 13 | 14 | if RUBY_VERSION <= "3.0" 15 | SUPPORTED_PG_VERSIONS.map do |pg_version| 16 | add_appraise_for(activerecord_version: "~> 5.2", pg_version: pg_version) 17 | end 18 | end 19 | 20 | SUPPORTED_PG_VERSIONS.map do |pg_version| 21 | add_appraise_for(activerecord_version: "~> 6.0", pg_version: pg_version) 22 | add_appraise_for(activerecord_version: "~> 6.1", pg_version: pg_version) 23 | end 24 | 25 | if RUBY_VERSION >= "2.7" 26 | SUPPORTED_PG_VERSIONS.map do |pg_version| 27 | add_appraise_for(activerecord_version: "~> 7.0", pg_version: pg_version) 28 | add_appraise_for(activerecord_version: "~> 7.1", pg_version: pg_version) 29 | end 30 | end 31 | 32 | if RUBY_VERSION >= "3.1" 33 | SUPPORTED_PG_VERSIONS.map do |pg_version| 34 | add_appraise_for(activerecord_version: "~> 7.2", pg_version: pg_version) 35 | end 36 | end 37 | 38 | if RUBY_VERSION >= "3.2" 39 | SUPPORTED_PG_VERSIONS.map do |pg_version| 40 | add_appraise_for(activerecord_version: "~> 8.0", pg_version: pg_version) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.2.2 2 | 3 | * [IMPROVE] Add Rails 7.1, 7.2, 8 support ([#50](https://github.com/sjke/pg_ltree/pull/50) by [@olivier-thatch](https://github.com/olivier-thatch)) 4 | * [IMPROVE] Improve Readme ([#45](https://github.com/sjke/pg_ltree/pull/45) by [@mweitzel](https://github.com/mweitzel)) 5 | 6 | ## 1.2.1 7 | 8 | * [FIXED] Fix `undefined method` issue with STI models ([#42](https://github.com/sjke/pg_ltree/pull/42) by [@mweitzel](https://github.com/mweitzel)) 9 | 10 | ## 1.2.0 11 | 12 | * [IMPROVE] Fully rewrote gem using rails autoload, modules and Rspec as main test framework ([#38](https://github.com/sjke/pg_ltree/pull/38)) 13 | 14 | ## 1.1.9 15 | 16 | * [IMPROVE] Add Rails 7 version support ([#29](https://github.com/sjke/pg_ltree/pull/33) by [@chrisortman](https://github.com/chrisortman)) 17 | * [IMPROVE] Add Ruby 3 version support ([#29](https://github.com/sjke/pg_ltree/pull/33) by [@chrisortman](https://github.com/chrisortman)) 18 | 19 | ## 1.1.8 20 | 21 | * [IMPROVE] Update rails/pg version support (rails 6, pg 1.0) 22 | * [FIXED] Fix #leaves used with a custom relation ([#29](https://github.com/sjke/pg_ltree/pull/29) by [@attilahorvath](https://github.com/attilahorvath)) 23 | 24 | ## 1.1.7 25 | 26 | * [IMPROVE] Update rails/pg version support (rails 5.2, pg 1.0) 27 | 28 | ## 1.1.6 29 | 30 | * [IMPROVE] Adds `table_name` prefix for a generating SQL queries ([#21](https://github.com/sjke/pg_ltree/pull/21) by [@arjan0307](https://github.com/arjan0307)) 31 | 32 | ## 1.1.5 33 | 34 | * [IMPROVE] Update pg version support ([#19](https://github.com/sjke/pg_ltree/pull/19) by [@askamist](https://github.com/askamist)) 35 | * [IMPROVE] Handler Rails 5.1 deprecation warnings ([#18](https://github.com/sjke/pg_ltree/pull/18) by [@HoyaBoya](https://github.com/HoyaBoya) and [#20](https://github.com/sjke/pg_ltree/pull/20) by [@sjke](https://github.com/sjke)) 36 | 37 | ## 1.1.4 38 | 39 | * [ADDED] Adds method to support ltxtquery value for searching ([#17](https://github.com/sjke/pg_ltree/pull/17) by [@sb1752](https://github.com/sb1752)) 40 | * [IMPROVE] Updated dependency version (Rails 5.1 and Pg adapter 0.21) ([#16](https://github.com/sjke/pg_ltree/pull/16) by [@sb1752](https://github.com/sb1752)) 41 | 42 | ## 1.1.3 43 | 44 | * [ADDED] node instance `height` method ([#6](https://github.com/sjke/pg_ltree/pull/6) by [@caulfield](https://github.com/caulfield)) 45 | * [FIXED] Fix #depth for a new record ([#9](https://github.com/sjke/pg_ltree/pull/9) by [@arjan0307](https://github.com/arjan0307)) 46 | * [IMPROVE] Use 'descendants' instead of 'descendents' ([#8](https://github.com/sjke/pg_ltree/pull/8) by [@CUnknown](https://github.com/CUnknown)) 47 | 48 | ## 1.1.2 49 | 50 | * [FIXED] Fix original #leaves method when merging with previous conditions was invalid. Overrided method in ScopedFor is useless now and it was deleted. 51 | 52 | ## 1.1.1 53 | 54 | * [FIXED] `#cascade_update` method 55 | 56 | ## 1.1.0 57 | 58 | * [ADDED] cascade update/destroy (Enabled by default) 59 | * [FIXED] destroy object 60 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Andrei Panamarenka 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PgLtree 2 | 3 | [![Gem Version](https://badge.fury.io/rb/pg_ltree.svg)](http://badge.fury.io/rb/pg_ltree) 4 | [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop) 5 | [![Build Status](https://github.com/sjke/pg_ltree/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/sjke/pg_ltree/actions/workflows/tests.yml?query=branch%3Amaster) 6 | [![RubyDoc](http://inch-ci.org/github/sjke/pg_ltree.svg?branch=master)](http://www.rubydoc.info/github/sjke/pg_ltree/) 7 | 8 | Adds PostgreSQL's [ltree](http://www.postgresql.org/docs/current/static/ltree.html) support for `ActiveRecord` models 9 | 10 | ## Installation 11 | 12 | Add this line to your application's Gemfile: 13 | 14 | gem 'pg_ltree' 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install pg_ltree 23 | 24 | ## Required 25 | 26 | * **_Ruby_** >= 2.7 27 | * **_Rails_** >= 5.2, < 9 28 | * **_Pg adapter (gem 'pg')_** >= 1.0, < 2 29 | 30 | ## How to use 31 | 32 | #### Basic usage 33 | 34 | Enable `ltree` extension: 35 | ```ruby 36 | class AddLtreeExtension < ActiveRecord::Migration 37 | def change 38 | enable_extension 'ltree' 39 | end 40 | end 41 | ``` 42 | 43 | Add column with `ltree` type for your model 44 | ```ruby 45 | class AddLtreePathToModel < ActiveRecord::Migration 46 | def change 47 | add_column :nodes, :path, :ltree 48 | add_index :nodes, :path, using: :gist 49 | end 50 | end 51 | ``` 52 | 53 | Initialize `ltree` module in your model 54 | ```ruby 55 | class Node < ActiveRecord::Base 56 | ltree :path 57 | # ltree :path, cascade_update: false # Disable cascade update 58 | # ltree :path, cascade_destroy: false # Disable cascade destory 59 | # ltree :path, cascade_update: false, cascade_destroy: false # Disable cascade callbacks 60 | end 61 | ``` 62 | 63 | #### Overriding `ltree_scope` 64 | 65 | You can cluster trees by overriding `ltree_scope`. In the following example a `LessonPlan` can have a set of `Tutorial`s. The tutorials in that lesson plan can be structured using a tree hierarchy. 66 | ```ruby 67 | class LessonPlan < ActiveRecord::Base 68 | has_many :tutorials 69 | end 70 | 71 | class Tutorial < ActiveRecord::Base 72 | belongs_to :lesson_plan 73 | 74 | ltree :path 75 | 76 | def ltree_scope 77 | self.class.base_class.where(lesson_plan_id:) 78 | end 79 | end 80 | ``` 81 | 82 | Using this pattern the `sibling`/`parent`/`descendent` methods (as well as cascading updates and destroys) will scope to pages associated with that lesson plan. This is in contrast to `Pages.all`, which would be the default scope. 83 | 84 | ## Contributing 85 | Bug reports and pull requests are welcome on GitHub at https://github.com/sjke/pg_ltree 86 | 87 | ## Changelog 88 | See [CHANGELOG](CHANGELOG.md) for details. 89 | 90 | ## License 91 | The gem is available as open source under the terms of the [MIT License](MIT-LICENSE). 92 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "standard/rake" 3 | require "rspec/core/rake_task" 4 | require "appraisal" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_52_pg_10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2", require: "active_record" 6 | gem "pg", "~> 1.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_52_pg_11.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2", require: "active_record" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_52_pg_12.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2", require: "active_record" 6 | gem "pg", "~> 1.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_52_pg_13.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2", require: "active_record" 6 | gem "pg", "~> 1.3" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_52_pg_14.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2", require: "active_record" 6 | gem "pg", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_52_pg_15.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2", require: "active_record" 6 | gem "pg", "~> 1.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_60_pg_10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0", require: "active_record" 6 | gem "pg", "~> 1.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_60_pg_11.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0", require: "active_record" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_60_pg_12.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0", require: "active_record" 6 | gem "pg", "~> 1.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_60_pg_13.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0", require: "active_record" 6 | gem "pg", "~> 1.3" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_60_pg_14.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0", require: "active_record" 6 | gem "pg", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_60_pg_15.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0", require: "active_record" 6 | gem "pg", "~> 1.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_61_pg_10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1", require: "active_record" 6 | gem "pg", "~> 1.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_61_pg_11.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1", require: "active_record" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_61_pg_12.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1", require: "active_record" 6 | gem "pg", "~> 1.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_61_pg_13.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1", require: "active_record" 6 | gem "pg", "~> 1.3" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_61_pg_14.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1", require: "active_record" 6 | gem "pg", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_61_pg_15.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1", require: "active_record" 6 | gem "pg", "~> 1.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_70_pg_10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0", require: "active_record" 6 | gem "pg", "~> 1.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_70_pg_11.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0", require: "active_record" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_70_pg_12.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0", require: "active_record" 6 | gem "pg", "~> 1.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_70_pg_13.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0", require: "active_record" 6 | gem "pg", "~> 1.3" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_70_pg_14.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0", require: "active_record" 6 | gem "pg", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_70_pg_15.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0", require: "active_record" 6 | gem "pg", "~> 1.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_71_pg_10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1", require: "active_record" 6 | gem "pg", "~> 1.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_71_pg_11.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1", require: "active_record" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_71_pg_12.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1", require: "active_record" 6 | gem "pg", "~> 1.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_71_pg_13.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1", require: "active_record" 6 | gem "pg", "~> 1.3" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_71_pg_14.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1", require: "active_record" 6 | gem "pg", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_71_pg_15.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1", require: "active_record" 6 | gem "pg", "~> 1.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_72_pg_10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2", require: "active_record" 6 | gem "pg", "~> 1.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_72_pg_11.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2", require: "active_record" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_72_pg_12.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2", require: "active_record" 6 | gem "pg", "~> 1.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_72_pg_13.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2", require: "active_record" 6 | gem "pg", "~> 1.3" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_72_pg_14.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2", require: "active_record" 6 | gem "pg", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_72_pg_15.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2", require: "active_record" 6 | gem "pg", "~> 1.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_80_pg_10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0", require: "active_record" 6 | gem "pg", "~> 1.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_80_pg_11.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0", require: "active_record" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_80_pg_12.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0", require: "active_record" 6 | gem "pg", "~> 1.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_80_pg_13.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0", require: "active_record" 6 | gem "pg", "~> 1.3" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_80_pg_14.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0", require: "active_record" 6 | gem "pg", "~> 1.4" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_80_pg_15.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0", require: "active_record" 6 | gem "pg", "~> 1.5" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /lib/pg_ltree.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | 3 | module PgLtree 4 | autoload :Base, "pg_ltree/base" 5 | end 6 | 7 | ActiveSupport.on_load(:active_record) do 8 | ActiveRecord::Base.include PgLtree::Base 9 | end 10 | -------------------------------------------------------------------------------- /lib/pg_ltree/base.rb: -------------------------------------------------------------------------------- 1 | require_relative "model" 2 | require_relative "callbacks" 3 | 4 | module PgLtree 5 | module Base 6 | extend ActiveSupport::Concern 7 | 8 | class_methods do 9 | attr_reader :ltree_options 10 | 11 | # Initialize ltree module for the model 12 | # 13 | # @param column [String] lTree column name 14 | # @param cascade_update [Boolean] Update all child nodes when the self path is changed 15 | # @param cascade_destroy [Boolean] Destroy all child nodes on self-destroying 16 | def ltree(column = :path, cascade_update: true, cascade_destroy: true, cascade: nil) 17 | if cascade 18 | ActiveSupport::Deprecation.warn("'cascade' param is deprecated. Use 'cascade_update' and 'cascade_destroy' instead.") 19 | end 20 | 21 | @ltree_options = { 22 | column: column, 23 | cascade_update: cascade.nil? ? cascade_update : cascade, 24 | cascade_destroy: cascade.nil? ? cascade_destroy : cascade 25 | } 26 | 27 | send(:include, PgLtree::Model) 28 | send(:include, PgLtree::Callbacks) 29 | end 30 | 31 | def ltree_options 32 | @ltree_options || superclass.ltree_options 33 | end 34 | 35 | def ltree_option_for(key) 36 | ltree_options[key] 37 | end 38 | end 39 | 40 | included do 41 | delegate :ltree_option_for, to: :class 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/pg_ltree/callbacks.rb: -------------------------------------------------------------------------------- 1 | module PgLtree 2 | module Callbacks 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | after_commit :cascade_update, on: :update, if: -> { ltree_option_for :cascade_update } 7 | after_commit :cascade_destroy, on: :destroy, if: -> { ltree_option_for :cascade_destroy } 8 | 9 | # Update child nodes path 10 | # 11 | # @return [ActiveRecord::Relation] 12 | def cascade_update 13 | ltree_scope 14 | .where(["#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_before_last_save]) 15 | .where(["#{self.class.table_name}.#{ltree_path_column} != ?", ltree_path]) 16 | .update_all ["#{ltree_path_column} = ? || subpath(#{ltree_path_column}, nlevel(?))", ltree_path, ltree_path_before_last_save] 17 | end 18 | 19 | # Destroy child nodes 20 | # 21 | # @return [ActiveRecord::Relation] 22 | def cascade_destroy 23 | ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_in_database).destroy_all 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/pg_ltree/model.rb: -------------------------------------------------------------------------------- 1 | module PgLtree 2 | module Model 3 | extend ActiveSupport::Concern 4 | 5 | class_methods do 6 | def ltree_path_column 7 | ltree_option_for :column 8 | end 9 | 10 | # Get roots 11 | # 12 | # @return [ActiveRecord::Relation] relations of node's roots 13 | def roots 14 | at_depth 1 15 | end 16 | 17 | # Get nodes on the level 18 | # 19 | # @param depth [Integer] Depth of the nodes 20 | # @return [ActiveRecord::Relation] relations of nodes for the depth 21 | def at_depth(depth) 22 | where "NLEVEL(#{table_name}.#{ltree_path_column}) = ?", depth 23 | end 24 | 25 | # Get all leaves 26 | # 27 | # @return [ActiveRecord::Relation] relations of node's leaves 28 | def leaves 29 | subquery = unscoped.select("#{table_name}.#{ltree_path_column}") 30 | .from("#{table_name} AS subquery") 31 | .where("#{table_name}.#{ltree_path_column} <> subquery.#{ltree_path_column}") 32 | .where("#{table_name}.#{ltree_path_column} @> subquery.#{ltree_path_column}") 33 | 34 | where.not ltree_path_column => subquery 35 | end 36 | 37 | # Get all with nodes when path liked the lquery 38 | # 39 | # @param lquery [String] ltree query 40 | # @return [ActiveRecord::Relation] relations of node' 41 | def where_path_liked(lquery) 42 | where "#{table_name}.#{ltree_path_column} ~ ?", lquery 43 | end 44 | 45 | # Get all nodes with path matching full-text-search-like pattern 46 | # 47 | # @param ltxtquery [String] ltree search query 48 | # @return [ActiveRecord::Relation] of matching nodes 49 | def where_path_matches_ltxtquery(ltxtquery) 50 | where "#{table_name}.#{ltree_path_column} @ ?", ltxtquery 51 | end 52 | end 53 | 54 | included do 55 | # Get default scope of ltree 56 | # 57 | # @return current class 58 | def ltree_scope 59 | self.class 60 | end 61 | 62 | # Get lTree column 63 | # 64 | # @return [String] ltree column name 65 | delegate :ltree_path_column, to: :ltree_scope 66 | 67 | # Get lTree value 68 | # 69 | # @return [String] ltree current value 70 | def ltree_path 71 | public_send ltree_path_column 72 | end 73 | 74 | # Get ltree original value before the save just occurred 75 | # https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html#method-i-attribute_before_last_save 76 | # 77 | # @return [String] ltree previous value 78 | def ltree_path_before_last_save 79 | public_send :attribute_before_last_save, ltree_path_column 80 | end 81 | 82 | # Get lTree previous value 83 | # originally +attribute_was+ used in before create/update, destroy won't call +save+ so this work 84 | # https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html#method-i-attribute_in_database 85 | # 86 | # @return [String] ltree value in database 87 | 88 | def ltree_path_in_database 89 | public_send :attribute_in_database, ltree_path_column 90 | end 91 | 92 | # Check what current node is root 93 | # 94 | # @return [Boolean] True - for root node, False - for childen node 95 | def root? 96 | depth == 1 97 | end 98 | 99 | # Get node height 100 | # 101 | # The height of a node is the number of edges 102 | # on the longest downward path between that node and a leaf. The leaf nodes have height zero, 103 | # and a tree with only a single node (hence both a root and leaf) has height zero. 104 | # Conventionally, an empty tree (tree with no nodes, if such are allowed) has depth and height −1 105 | # 106 | # @return [Number] height of the given node. Height of the tree for root node. 107 | def height 108 | self_and_descendants.maximum("NLEVEL(#{ltree_path_column})") - depth.to_i 109 | end 110 | 111 | # Get node depth 112 | # 113 | # @return [Integer] node depth 114 | def depth 115 | ActiveRecord::Base.connection.select_all("SELECT NLEVEL('#{ltree_path}')").rows.flatten.first.to_i 116 | end 117 | 118 | # Get root of the node 119 | # 120 | # return [Object] root node 121 | def root 122 | ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} = SUBPATH(?, 0, 1)", ltree_path).first 123 | end 124 | 125 | # Get parent of the node 126 | # 127 | # return [Object] root node 128 | def parent 129 | ltree_scope.find_by "#{self.class.table_name}.#{ltree_path_column} = SUBPATH(?, 0, NLEVEL(?) - 1)", ltree_path, 130 | ltree_path 131 | end 132 | 133 | # Get leaves of the node 134 | # 135 | # @return [ActiveRecord::Relation] 136 | def leaves 137 | ltree_scope.leaves.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", 138 | ltree_path).where.not ltree_path_column => ltree_path 139 | end 140 | 141 | # Check what current node have leaves 142 | # 143 | # @return [Boolean] True - if node have leaves, False - if node doesn't have leaves 144 | def leaf? 145 | leaves.count == 0 146 | end 147 | 148 | # Get self and ancestors 149 | # 150 | # @return [ActiveRecord::Relation] 151 | def self_and_ancestors 152 | ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} @> ?", ltree_path) 153 | end 154 | 155 | # Get ancestors 156 | # 157 | # @return [ActiveRecord::Relation] 158 | def ancestors 159 | self_and_ancestors.where.not ltree_path_column => ltree_path 160 | end 161 | 162 | # Get self and descendants 163 | # 164 | # @return [ActiveRecord::Relation] 165 | def self_and_descendants 166 | ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path) 167 | end 168 | 169 | # Get descendants 170 | # 171 | # @return [ActiveRecord::Relation] 172 | def descendants 173 | self_and_descendants.where.not ltree_path_column => ltree_path 174 | end 175 | 176 | # Get self and siblings 177 | # 178 | # @return [ActiveRecord::Relation] 179 | def self_and_siblings 180 | ltree_scope.where( 181 | "SUBPATH(?, 0, NLEVEL(?) - 1) @> #{self.class.table_name}.#{ltree_path_column} AND nlevel(#{self.class.table_name}.#{ltree_path_column}) = NLEVEL(?)", 182 | ltree_path, ltree_path, ltree_path 183 | ) 184 | end 185 | 186 | # Get siblings 187 | # 188 | # @return [ActiveRecord::Relation] 189 | def siblings 190 | self_and_siblings.where.not ltree_path_column => ltree_path 191 | end 192 | 193 | # Get children 194 | # 195 | # @return [ActiveRecord::Relation] 196 | def children 197 | ltree_scope.where "? @> #{self.class.table_name}.#{ltree_path_column} AND nlevel(#{self.class.table_name}.#{ltree_path_column}) = NLEVEL(?) + 1", 198 | ltree_path, ltree_path 199 | end 200 | 201 | # Update all childen for current path 202 | # 203 | # @return [ActiveRecord::Relation] 204 | def cascade_update 205 | ltree_scope 206 | .where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_before_last_save) 207 | .where("#{self.class.table_name}.#{ltree_path_column} != ?", ltree_path) 208 | .update_all("#{ltree_path_column} = ? || subpath(#{ltree_path_column}, nlevel(?))", ltree_path, ltree_path_before_last_save) 209 | end 210 | 211 | # Delete all children for current path 212 | # 213 | # @return [ActiveRecord::Relation] 214 | def cascade_destroy 215 | ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_in_database).destroy_all 216 | end 217 | end 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /lib/pg_ltree/version.rb: -------------------------------------------------------------------------------- 1 | module PgLtree 2 | VERSION = "1.2.2".freeze 3 | end 4 | -------------------------------------------------------------------------------- /pg_ltree.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path("../lib", __FILE__) 2 | 3 | # Maintain your gem's version: 4 | require "pg_ltree/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "pg_ltree" 9 | s.version = PgLtree::VERSION 10 | s.authors = ["Andrei Panamarenka"] 11 | s.email = ["andrei.panamarenka@gmail.com"] 12 | s.homepage = "https://github.com/sjke/pg_ltree" 13 | s.summary = "Organize ActiveRecord model into a tree structure using PostgreSQL LTree" 14 | s.description = "Organize ActiveRecord model into a tree structure using PostgreSQL LTree" 15 | s.license = "MIT" 16 | s.required_ruby_version = ">= 2.7.0" 17 | 18 | s.files = Dir["{lib}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"] 19 | 20 | s.add_dependency "activerecord", ">= 5.2", "< 9.0" 21 | s.add_dependency "pg", ">= 0.19", "< 2" 22 | 23 | s.add_development_dependency "bundler" 24 | s.add_development_dependency "rake" 25 | s.add_development_dependency "pry" 26 | s.add_development_dependency "standard" 27 | s.add_development_dependency "yard", "~> 0.9.28" 28 | s.add_development_dependency "appraisal", "~> 2.5" 29 | s.add_development_dependency "rspec", "~> 3.11" 30 | s.add_development_dependency "database_cleaner", "~> 2.0" 31 | end 32 | -------------------------------------------------------------------------------- /spec/database.yml.sample: -------------------------------------------------------------------------------- 1 | adapter: postgresql 2 | host: localhost 3 | username: postgres 4 | password: postgres 5 | database: pg_ltree_test 6 | -------------------------------------------------------------------------------- /spec/pg_ltree/base_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe PgLtree::Base do 4 | subject { model_class } 5 | 6 | let!(:model_class) do 7 | Class.new(ActiveRecord::Base) do 8 | self.table_name = "nodes" 9 | ltree :path 10 | end 11 | end 12 | 13 | let!(:sub_class) do 14 | Class.new(model_class) {} 15 | end 16 | 17 | describe "inject PgLtree modules" do 18 | describe "when call #ltree" do 19 | subject { model_class.included_modules } 20 | 21 | it "includes PgLtree::Model" do 22 | expect(subject).to include(PgLtree::Model) 23 | end 24 | 25 | it "includes PgLtree::Model" do 26 | expect(subject).to include(PgLtree::Callbacks) 27 | end 28 | end 29 | 30 | describe "when not call #ltree" do 31 | subject { Class.new(ActiveRecord::Base).included_modules } 32 | 33 | it "not includes PgLtree::Model" do 34 | expect(subject).not_to include(PgLtree::Model) 35 | end 36 | 37 | it "not includes PgLtree::Model" do 38 | expect(subject).not_to include(PgLtree::Callbacks) 39 | end 40 | end 41 | end 42 | 43 | describe "configuration" do 44 | it "defines ltree options" do 45 | expect(subject.ltree_options).to eq(cascade_destroy: true, cascade_update: true, column: :path) 46 | end 47 | 48 | { 49 | column: :path, 50 | cascade_destroy: true, 51 | cascade_update: true, 52 | unknown_key: nil 53 | }.map do |key, value| 54 | it "returns ltree option '#{key}' with '#{value}' as value" do 55 | expect(subject.ltree_option_for(key)).to eq(value) 56 | end 57 | end 58 | 59 | it "subclass of ltree can view options" do 60 | expect(sub_class.ltree_options).to eq(cascade_destroy: true, cascade_update: true, column: :path) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/pg_ltree/callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe PgLtree::Callbacks do 4 | context "update records" do 5 | describe "when cascade_update is true" do 6 | subject do 7 | Class.new(ActiveRecord::Base) do 8 | self.table_name = "nodes" 9 | ltree :path, cascade_update: true 10 | end 11 | end 12 | 13 | before do 14 | subject.create!([{path: "Top"}, {path: "Top.Science"}]) 15 | expect(subject.pluck(:path)).to include("Top", "Top.Science") 16 | end 17 | 18 | it "updates child record paths when parent path in changed" do 19 | subject.find_by(path: "Top").update path: "NewTop" 20 | 21 | expect(subject.pluck(:path)).to include("NewTop", "NewTop.Science") 22 | end 23 | end 24 | 25 | describe "when cascade_update is false" do 26 | subject do 27 | Class.new(ActiveRecord::Base) do 28 | self.table_name = "nodes" 29 | ltree :path, cascade_update: false 30 | end 31 | end 32 | 33 | before do 34 | subject.create!([{path: "Top"}, {path: "Top.Science"}]) 35 | expect(subject.pluck(:path)).to include("Top", "Top.Science") 36 | end 37 | 38 | it "not updates child record paths when parent path in changed" do 39 | subject.find_by(path: "Top").update path: "NewTop" 40 | 41 | expect(subject.pluck(:path)).to include("NewTop", "Top.Science") 42 | end 43 | end 44 | 45 | describe "cascade when single table inheritance records" do 46 | let!(:base_class) do 47 | Class.new(ActiveRecord::Base) do 48 | self.table_name = "nodes" 49 | ltree :path, cascade_update: true 50 | def ltree_scope 51 | self.class.base_class 52 | end 53 | end 54 | end 55 | subject { Class.new(base_class) {} } 56 | 57 | before do 58 | subject.create!([{path: "Top"}, {path: "Top.Science"}]) 59 | expect(subject.pluck(:path)).to include("Top", "Top.Science") 60 | end 61 | 62 | it "updates child record paths when parent path in changed" do 63 | subject.find_by(path: "Top").update path: "NewTop" 64 | 65 | expect(subject.pluck(:path)).to include("NewTop", "NewTop.Science") 66 | end 67 | end 68 | end 69 | 70 | context "desctroy records" do 71 | describe "when cascade_destroy is true" do 72 | subject do 73 | Class.new(ActiveRecord::Base) do 74 | self.table_name = "nodes" 75 | ltree :path, cascade_destroy: true 76 | end 77 | end 78 | 79 | before do 80 | subject.create!([{path: "Top"}, {path: "Top.Science"}]) 81 | expect(subject.pluck(:path)).to include("Top", "Top.Science") 82 | end 83 | 84 | it "deletes child records when parent is destroyed" do 85 | subject.find_by(path: "Top").destroy 86 | 87 | expect(subject.pluck(:path)).to eq([]) 88 | end 89 | end 90 | 91 | describe "when cascade_destroy is false" do 92 | subject do 93 | Class.new(ActiveRecord::Base) do 94 | self.table_name = "nodes" 95 | ltree :path, cascade_destroy: false 96 | end 97 | end 98 | 99 | before do 100 | subject.create!([{path: "Top"}, {path: "Top.Science"}]) 101 | expect(subject.pluck(:path)).to include("Top", "Top.Science") 102 | end 103 | 104 | it "not deletes child records when parent is destroy" do 105 | subject.find_by(path: "Top").destroy 106 | 107 | expect(subject.pluck(:path)).to include("Top.Science") 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/pg_ltree/model_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe PgLtree::Model do 4 | subject do 5 | Class.new(ActiveRecord::Base) do 6 | self.table_name = "nodes" 7 | ltree :path 8 | end 9 | end 10 | 11 | before do 12 | subject.create!([ 13 | {path: "Top"}, 14 | {path: "Top.Science"}, 15 | {path: "Top.Science.Astronomy"}, 16 | {path: "Top.Science.Astronomy.Astrophysics"}, 17 | {path: "Top.Science.Astronomy.Cosmology"}, 18 | {path: "Top.Hobbies"}, 19 | {path: "Top.Hobbies.Amateurs_Astronomy"}, 20 | {path: "Top.Collections"}, 21 | {path: "Top.Collections.Pictures"}, 22 | {path: "Top.Collections.Pictures.Astronomy"}, 23 | {path: "Top.Collections.Pictures.Astronomy.Stars"}, 24 | {path: "Top.Collections.Pictures.Astronomy.Galaxies"}, 25 | {path: "Top.Collections.Pictures.Astronomy.Astronauts"}, 26 | {path: "Top.Collections.Videos"}, 27 | {path: "Top.Collections.Videos.Vacation"}, 28 | {path: "Top.Collections.Videos.NewYear"} 29 | ]) 30 | end 31 | 32 | describe "#roots" do 33 | it "returns all root paths" do 34 | expect(subject.roots.pluck(:path)).to include("Top") 35 | end 36 | end 37 | 38 | describe "#at_depth" do 39 | it "returns empty array with when depth is zero" do 40 | expect(subject.at_depth(0).pluck(:path)).to eq([]) 41 | end 42 | 43 | it "returns nodes on requested level" do 44 | expect(subject.at_depth(5).pluck(:path)).to include(*%w[ 45 | Top.Collections.Pictures.Astronomy.Stars 46 | Top.Collections.Pictures.Astronomy.Galaxies 47 | Top.Collections.Pictures.Astronomy.Astronauts 48 | ]) 49 | end 50 | 51 | it "returns empty array when depth is outside available range" do 52 | expect(subject.at_depth(100_000).pluck(:path)).to eq([]) 53 | end 54 | end 55 | 56 | describe "#leaves" do 57 | it "returns all paths which are leaves" do 58 | expect(subject.leaves.pluck(:path)).to include(*%w[ 59 | Top.Science.Astronomy.Astrophysics 60 | Top.Science.Astronomy.Cosmology 61 | Top.Hobbies.Amateurs_Astronomy 62 | Top.Collections.Pictures.Astronomy.Stars 63 | Top.Collections.Pictures.Astronomy.Galaxies 64 | Top.Collections.Pictures.Astronomy.Astronauts 65 | Top.Collections.Videos.Vacation 66 | Top.Collections.Videos.NewYear 67 | ]) 68 | end 69 | 70 | it "returns paths which are leaves except ignored one" do 71 | expect(subject.where("path <> 'Top.Collections.Pictures.Astronomy.Stars'").leaves.pluck(:path)).to include(*%w[ 72 | Top.Science.Astronomy.Astrophysics 73 | Top.Science.Astronomy.Cosmology 74 | Top.Hobbies.Amateurs_Astronomy 75 | Top.Collections.Pictures.Astronomy.Galaxies 76 | Top.Collections.Pictures.Astronomy.Astronauts 77 | Top.Collections.Videos.Vacation 78 | Top.Collections.Videos.NewYear 79 | ]) 80 | end 81 | end 82 | 83 | describe "#where_path_liked" do 84 | it "returns array of paths which liked to query" do 85 | expect(subject.where_path_liked("*{2}.Astronomy|Pictures").pluck(:path)).to include(*%w[ 86 | Top.Science.Astronomy 87 | Top.Collections.Pictures 88 | ]) 89 | end 90 | end 91 | 92 | describe "#where_path_matches_ltxtquery" do 93 | it "returns array of path which matched to query" do 94 | expect(subject.where_path_matches_ltxtquery("Astro*% & !pictures@").pluck(:path)).to include(*%w[ 95 | Top.Science.Astronomy 96 | Top.Science.Astronomy.Astrophysics 97 | Top.Science.Astronomy.Cosmology 98 | Top.Hobbies.Amateurs_Astronomy 99 | ]) 100 | end 101 | end 102 | 103 | describe ".height" do 104 | it "returns height for root path" do 105 | expect(subject.find_by(path: "Top").height).to eq(4) 106 | end 107 | 108 | it "returns height for leave path" do 109 | expect(subject.find_by(path: "Top.Science.Astronomy.Astrophysics").height).to be_zero 110 | end 111 | end 112 | 113 | describe ".depth" do 114 | it "returns depth for selected record from db" do 115 | expect(subject.find_by(path: "Top.Hobbies.Amateurs_Astronomy").depth).to eq(3) 116 | end 117 | 118 | it "returns depth for new record" do 119 | expect(subject.new(path: "Group.Nested.Depth.Value").depth).to eq(4) 120 | end 121 | end 122 | 123 | describe ".root" do 124 | it "returns root paths for selected record" do 125 | expect(subject.find_by(path: "Top.Hobbies.Amateurs_Astronomy").root.path).to eq("Top") 126 | end 127 | end 128 | 129 | describe ".root?" do 130 | it "returns true when selected record is root" do 131 | expect(subject.find_by(path: "Top").root?).to be_truthy 132 | end 133 | 134 | it "returns false when selected record is not root" do 135 | expect(subject.find_by(path: "Top.Science").root?).to be_falsey 136 | end 137 | end 138 | 139 | describe ".parent" do 140 | it "returns parenth path for selected record" do 141 | expect(subject.find_by(path: "Top.Collections.Pictures.Astronomy.Astronauts").parent.path).to eq("Top.Collections.Pictures.Astronomy") 142 | end 143 | end 144 | 145 | describe ".children" do 146 | it "returns children paths for selected record" do 147 | expect(subject.find_by(path: "Top.Hobbies").children.pluck(:path)).to include(*%w[Top.Hobbies.Amateurs_Astronomy]) 148 | end 149 | end 150 | 151 | describe ".leaves" do 152 | it "returns leave paths for selected record" do 153 | expect(subject.find_by(path: "Top.Science").leaves.pluck(:path)).to include(*%w[ 154 | Top.Science.Astronomy.Astrophysics 155 | Top.Science.Astronomy.Cosmology 156 | ]) 157 | end 158 | end 159 | 160 | describe ".leaf?" do 161 | it "returns false when selected record is leaf" do 162 | expect(subject.find_by(path: "Top").leaf?).to be_falsey 163 | end 164 | 165 | it "returns true when selected record is not leaf" do 166 | expect(subject.find_by(path: "Top.Collections.Pictures.Astronomy.Astronauts").leaf?).to be_truthy 167 | end 168 | end 169 | 170 | describe ".self_and_ancestors" do 171 | it "returns self and ancestor paths for selected record" do 172 | expect(subject.find_by(path: "Top.Collections.Pictures.Astronomy.Astronauts").self_and_ancestors.pluck(:path)).to include(*%w[ 173 | Top 174 | Top.Collections 175 | Top.Collections.Pictures 176 | Top.Collections.Pictures.Astronomy 177 | Top.Collections.Pictures.Astronomy.Astronauts 178 | ]) 179 | end 180 | end 181 | 182 | describe ".ancestors" do 183 | it "returns ancestor paths for selected record" do 184 | expect(subject.find_by(path: "Top.Collections.Pictures.Astronomy.Astronauts").ancestors.pluck(:path)).to include(*%w[ 185 | Top 186 | Top.Collections 187 | Top.Collections.Pictures 188 | Top.Collections.Pictures.Astronomy 189 | ]) 190 | end 191 | end 192 | 193 | describe ".self_and_descendants" do 194 | it "returns self and descendant paths for selected record" do 195 | expect(subject.find_by(path: "Top.Science").self_and_descendants.pluck(:path)).to include(*%w[ 196 | Top.Science 197 | Top.Science.Astronomy 198 | Top.Science.Astronomy.Astrophysics 199 | Top.Science.Astronomy.Cosmology 200 | ]) 201 | end 202 | end 203 | 204 | describe ".descendants" do 205 | it "returns descendant paths for selected record" do 206 | expect(subject.find_by(path: "Top.Science").descendants.pluck(:path)).to include(*%w[ 207 | Top.Science.Astronomy 208 | Top.Science.Astronomy.Astrophysics 209 | Top.Science.Astronomy.Cosmology 210 | ]) 211 | end 212 | end 213 | 214 | describe ".self_and_siblings" do 215 | it "returns self and sibling paths for selected record" do 216 | expect(subject.find_by(path: "Top.Collections.Pictures.Astronomy.Stars").self_and_siblings.pluck(:path)).to include(*%w[ 217 | Top.Collections.Pictures.Astronomy.Stars 218 | Top.Collections.Pictures.Astronomy.Galaxies 219 | Top.Collections.Pictures.Astronomy.Astronauts 220 | ]) 221 | end 222 | end 223 | 224 | describe ".siblings" do 225 | it "returns sibling paths for selected record" do 226 | expect(subject.find_by(path: "Top.Collections.Pictures.Astronomy.Stars").siblings.pluck(:path)).to include(*%w[ 227 | Top.Collections.Pictures.Astronomy.Galaxies 228 | Top.Collections.Pictures.Astronomy.Astronauts 229 | ]) 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | require "active_record" 4 | require "pg_ltree" 5 | 6 | require_relative "support/database" 7 | require_relative "support/database_cleaner" 8 | 9 | RSpec.configure do |config| 10 | config.order = :random 11 | config.disable_monkey_patching! 12 | config.expect_with :rspec do |c| 13 | c.syntax = :expect 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/database.rb: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | begin 4 | db_connection = YAML.load_file(File.expand_path("../database.yml", __dir__)) 5 | rescue => e 6 | warn e.message 7 | warn "Copy `test/database.yml.sample` to `test/database.yml` and configure connection to DB" 8 | exit 0 9 | end 10 | 11 | ActiveRecord::Base.establish_connection(db_connection) 12 | ActiveRecord::Schema.verbose = false 13 | 14 | require_relative "schema" 15 | -------------------------------------------------------------------------------- /spec/support/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | require "database_cleaner/active_record" 2 | 3 | RSpec.configure do |config| 4 | config.before(:suite) do 5 | DatabaseCleaner.strategy = :transaction 6 | DatabaseCleaner.clean_with(:truncation) 7 | end 8 | 9 | config.around do |example| 10 | DatabaseCleaner.cleaning do 11 | example.run 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | enable_extension "plpgsql" 3 | enable_extension "ltree" 4 | 5 | create_table "nodes", force: :cascade do |t| 6 | t.ltree "path" 7 | end 8 | end 9 | --------------------------------------------------------------------------------