diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e8a875..adbb9f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,20 +1,24 @@ name: Gem Tests -on: +on: pull_request: push: branches: - master jobs: - run: - name: Run Tests for Ruby ${{ matrix.ruby }} - + test: runs-on: ubuntu-latest + name: Ruby ${{ matrix.ruby }} + strategy: + fail-fast: false + matrix: + # Testing against all major supported Ruby versions + ruby: ['4.0', '3.5', '3.4', '3.3', '3.2', '3.1', '3.0', '2.7'] services: postgres: - image: postgres + image: postgres:16 env: POSTGRES_PASSWORD: postgres POSTGRES_DB: pg_ltree_test @@ -26,20 +30,6 @@ jobs: --health-timeout 5s --health-retries 5 - strategy: - fail-fast: false - matrix: - ruby: - - 'head' - - '3.4' - - '3.3' - - '3.2' - - '3.1' - - '3.0' - - '2.7' - - continue-on-error: ${{ matrix.ruby == 'head' }} - steps: - uses: actions/checkout@v4 @@ -49,11 +39,29 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Setup DB + - name: Setup DB config run: cp spec/database.yml.sample spec/database.yml - - - name: Install dependencies + + - name: Install Appraisal dependencies run: bundle exec appraisal install - - name: Run tests + - name: Run Tests (Appraisal) run: bundle exec appraisal rake spec + env: + PGHOST: localhost + PGUSER: postgres + PGPASSWORD: postgres + + # Dedicated job for linting to fail fast + lint: + runs-on: ubuntu-latest + name: Linting + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - name: Run Lint + run: bundle exec rake lint diff --git a/Appraisals b/Appraisals index 47349e9..d91f743 100644 --- a/Appraisals +++ b/Appraisals @@ -17,7 +17,7 @@ def add_appraise_for(activerecord_version:, pg_version:) end end -SUPPORTED_PG_VERSIONS = ["~> 1.0", "~> 1.1", "~> 1.2", "~> 1.3", "~> 1.4", "~> 1.5"] +SUPPORTED_PG_VERSIONS = ["~> 1.0", "~> 1.1", "~> 1.2", "~> 1.3", "~> 1.4", "~> 1.5", "~> 1.6"] if Gem::Version.new(RUBY_VERSION) <= Gem::Version.new("3.0") SUPPORTED_PG_VERSIONS.map do |pg_version| @@ -46,5 +46,6 @@ end if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2") SUPPORTED_PG_VERSIONS.map do |pg_version| add_appraise_for(activerecord_version: "~> 8.0", pg_version: pg_version) + add_appraise_for(activerecord_version: "~> 8.1", pg_version: pg_version) end end diff --git a/Rakefile b/Rakefile index 12fff99..dbd2649 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,15 @@ require "standard/rake" require "rspec/core/rake_task" require "appraisal" +desc "Run all tests" RSpec::Core::RakeTask.new(:spec) -task default: :spec +desc "Run linting" +task lint: :standard + +desc "Run all tests across all appraisals" +task :test_all do + sh "bundle exec appraisal rake spec" +end + +task default: [:lint, :spec] diff --git a/gemfiles/activerecord_60_pg_16.gemfile b/gemfiles/activerecord_60_pg_16.gemfile new file mode 100644 index 0000000..605e189 --- /dev/null +++ b/gemfiles/activerecord_60_pg_16.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 6.0", require: "active_record" +gem "pg", "~> 1.6" + +gemspec path: "../" diff --git a/gemfiles/activerecord_61_pg_16.gemfile b/gemfiles/activerecord_61_pg_16.gemfile new file mode 100644 index 0000000..988eae4 --- /dev/null +++ b/gemfiles/activerecord_61_pg_16.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 6.1", require: "active_record" +gem "pg", "~> 1.6" + +gemspec path: "../" diff --git a/gemfiles/activerecord_70_pg_16.gemfile b/gemfiles/activerecord_70_pg_16.gemfile new file mode 100644 index 0000000..0ed9c05 --- /dev/null +++ b/gemfiles/activerecord_70_pg_16.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 7.0", require: "active_record" +gem "pg", "~> 1.6" + +gemspec path: "../" diff --git a/gemfiles/activerecord_71_pg_16.gemfile b/gemfiles/activerecord_71_pg_16.gemfile new file mode 100644 index 0000000..18dc8e6 --- /dev/null +++ b/gemfiles/activerecord_71_pg_16.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 7.1", require: "active_record" +gem "pg", "~> 1.6" + +gemspec path: "../" diff --git a/gemfiles/activerecord_72_pg_16.gemfile b/gemfiles/activerecord_72_pg_16.gemfile new file mode 100644 index 0000000..888da16 --- /dev/null +++ b/gemfiles/activerecord_72_pg_16.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 7.2", require: "active_record" +gem "pg", "~> 1.6" + +gemspec path: "../" diff --git a/gemfiles/activerecord_80_pg_16.gemfile b/gemfiles/activerecord_80_pg_16.gemfile new file mode 100644 index 0000000..a3b3445 --- /dev/null +++ b/gemfiles/activerecord_80_pg_16.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.0", require: "active_record" +gem "pg", "~> 1.6" + +gemspec path: "../" diff --git a/gemfiles/activerecord_81_pg_10.gemfile b/gemfiles/activerecord_81_pg_10.gemfile new file mode 100644 index 0000000..cf74617 --- /dev/null +++ b/gemfiles/activerecord_81_pg_10.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.1", require: "active_record" +gem "pg", "~> 1.0" + +gemspec path: "../" diff --git a/gemfiles/activerecord_81_pg_11.gemfile b/gemfiles/activerecord_81_pg_11.gemfile new file mode 100644 index 0000000..41cb27e --- /dev/null +++ b/gemfiles/activerecord_81_pg_11.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.1", require: "active_record" +gem "pg", "~> 1.1" + +gemspec path: "../" diff --git a/gemfiles/activerecord_81_pg_12.gemfile b/gemfiles/activerecord_81_pg_12.gemfile new file mode 100644 index 0000000..52df1af --- /dev/null +++ b/gemfiles/activerecord_81_pg_12.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.1", require: "active_record" +gem "pg", "~> 1.2" + +gemspec path: "../" diff --git a/gemfiles/activerecord_81_pg_13.gemfile b/gemfiles/activerecord_81_pg_13.gemfile new file mode 100644 index 0000000..76b3b85 --- /dev/null +++ b/gemfiles/activerecord_81_pg_13.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.1", require: "active_record" +gem "pg", "~> 1.3" + +gemspec path: "../" diff --git a/gemfiles/activerecord_81_pg_14.gemfile b/gemfiles/activerecord_81_pg_14.gemfile new file mode 100644 index 0000000..3968815 --- /dev/null +++ b/gemfiles/activerecord_81_pg_14.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.1", require: "active_record" +gem "pg", "~> 1.4" + +gemspec path: "../" diff --git a/gemfiles/activerecord_81_pg_15.gemfile b/gemfiles/activerecord_81_pg_15.gemfile new file mode 100644 index 0000000..feaad68 --- /dev/null +++ b/gemfiles/activerecord_81_pg_15.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.1", require: "active_record" +gem "pg", "~> 1.5" + +gemspec path: "../" diff --git a/gemfiles/activerecord_81_pg_16.gemfile b/gemfiles/activerecord_81_pg_16.gemfile new file mode 100644 index 0000000..c1fde00 --- /dev/null +++ b/gemfiles/activerecord_81_pg_16.gemfile @@ -0,0 +1,8 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 8.1", require: "active_record" +gem "pg", "~> 1.6" + +gemspec path: "../" diff --git a/lib/pg_ltree/base.rb b/lib/pg_ltree/base.rb index 3845b38..70b5e0d 100644 --- a/lib/pg_ltree/base.rb +++ b/lib/pg_ltree/base.rb @@ -1,44 +1,10 @@ require_relative "model" require_relative "callbacks" +require_relative "configuration" module PgLtree module Base extend ActiveSupport::Concern - - class_methods do - attr_reader :ltree_options - - # Initialize ltree module for the model - # - # @param column [String] lTree column name - # @param cascade_update [Boolean] Update all child nodes when the self path is changed - # @param cascade_destroy [Boolean] Destroy all child nodes on self-destroying - def ltree(column = :path, cascade_update: true, cascade_destroy: true, cascade: nil) - if cascade - ActiveSupport::Deprecation.warn("'cascade' param is deprecated. Use 'cascade_update' and 'cascade_destroy' instead.") - end - - @ltree_options = { - column: column, - cascade_update: cascade.nil? ? cascade_update : cascade, - cascade_destroy: cascade.nil? ? cascade_destroy : cascade - } - - send(:include, PgLtree::Model) - send(:include, PgLtree::Callbacks) - end - - def ltree_options - @ltree_options || superclass.ltree_options - end - - def ltree_option_for(key) - ltree_options[key] - end - end - - included do - delegate :ltree_option_for, to: :class - end + include Configuration end end diff --git a/lib/pg_ltree/callbacks.rb b/lib/pg_ltree/callbacks.rb index 356b7a5..eccb151 100644 --- a/lib/pg_ltree/callbacks.rb +++ b/lib/pg_ltree/callbacks.rb @@ -5,23 +5,6 @@ module Callbacks included do after_commit :cascade_update, on: :update, if: -> { ltree_option_for :cascade_update } after_commit :cascade_destroy, on: :destroy, if: -> { ltree_option_for :cascade_destroy } - - # Update child nodes path - # - # @return [ActiveRecord::Relation] - def cascade_update - ltree_scope - .where(["#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_before_last_save]) - .where(["#{self.class.table_name}.#{ltree_path_column} != ?", ltree_path]) - .update_all ["#{ltree_path_column} = ? || subpath(#{ltree_path_column}, nlevel(?))", ltree_path, ltree_path_before_last_save] - end - - # Destroy child nodes - # - # @return [ActiveRecord::Relation] - def cascade_destroy - ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_in_database).destroy_all - end end end end diff --git a/lib/pg_ltree/configuration.rb b/lib/pg_ltree/configuration.rb new file mode 100644 index 0000000..be24582 --- /dev/null +++ b/lib/pg_ltree/configuration.rb @@ -0,0 +1,43 @@ +module PgLtree + module Configuration + extend ActiveSupport::Concern + + class_methods do + attr_reader :ltree_options + + # Initialize ltree module for the model + # + # @param column [String] lTree column name + # @param cascade_update [Boolean] Update all child nodes when the self path is changed + # @param cascade_destroy [Boolean] Destroy all child nodes on self-destroying + def ltree(column = :path, cascade_update: true, cascade_destroy: true, cascade: nil) + if cascade + ActiveSupport::Deprecation.warn("'cascade' param is deprecated. Use 'cascade_update' and 'cascade_destroy' instead.") + end + + @ltree_options = { + column: column, + cascade_update: cascade.nil? ? cascade_update : cascade, + cascade_destroy: cascade.nil? ? cascade_destroy : cascade + } + + send(:include, PgLtree::Model) + send(:include, PgLtree::Callbacks) + end + + def ltree_options + @ltree_options || superclass.ltree_options + rescue NoMethodError + nil + end + + def ltree_option_for(key) + ltree_options[key] if ltree_options + end + end + + included do + delegate :ltree_option_for, to: :class + end + end +end diff --git a/lib/pg_ltree/model.rb b/lib/pg_ltree/model.rb index d716068..0181c33 100644 --- a/lib/pg_ltree/model.rb +++ b/lib/pg_ltree/model.rb @@ -1,219 +1,19 @@ +require_relative "model/querying" +require_relative "model/traversal" +require_relative "model/predicates" +require_relative "model/path_info" +require_relative "model/cascade" + module PgLtree module Model extend ActiveSupport::Concern - class_methods do - def ltree_path_column - ltree_option_for :column - end - - # Get roots - # - # @return [ActiveRecord::Relation] relations of node's roots - def roots - at_depth 1 - end - - # Get nodes on the level - # - # @param depth [Integer] Depth of the nodes - # @return [ActiveRecord::Relation] relations of nodes for the depth - def at_depth(depth) - where "NLEVEL(#{table_name}.#{ltree_path_column}) = ?", depth - end - - # Get all leaves - # - # @return [ActiveRecord::Relation] relations of node's leaves - def leaves - subquery = unscoped.select("#{table_name}.#{ltree_path_column}") - .from("#{table_name} AS subquery") - .where("#{table_name}.#{ltree_path_column} <> subquery.#{ltree_path_column}") - .where("#{table_name}.#{ltree_path_column} @> subquery.#{ltree_path_column}") - - where.not ltree_path_column => subquery - end - - # Get all with nodes when path liked the lquery - # - # @param lquery [String] ltree query - # @return [ActiveRecord::Relation] relations of node' - def where_path_liked(lquery) - where "#{table_name}.#{ltree_path_column} ~ ?", lquery - end - - # Get all nodes with path matching full-text-search-like pattern - # - # @param ltxtquery [String] ltree search query - # @return [ActiveRecord::Relation] of matching nodes - def where_path_matches_ltxtquery(ltxtquery) - where "#{table_name}.#{ltree_path_column} @ ?", ltxtquery - end - end - included do - # Get default scope of ltree - # - # @return current class - def ltree_scope - self.class - end - - # Get lTree column - # - # @return [String] ltree column name - delegate :ltree_path_column, to: :ltree_scope - - # Get lTree value - # - # @return [String] ltree current value - def ltree_path - public_send ltree_path_column - end - - # Get ltree original value before the save just occurred - # https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html#method-i-attribute_before_last_save - # - # @return [String] ltree previous value - def ltree_path_before_last_save - public_send :attribute_before_last_save, ltree_path_column - end - - # Get lTree previous value - # originally +attribute_was+ used in before create/update, destroy won't call +save+ so this work - # https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Dirty.html#method-i-attribute_in_database - # - # @return [String] ltree value in database - - def ltree_path_in_database - public_send :attribute_in_database, ltree_path_column - end - - # Check what current node is root - # - # @return [Boolean] True - for root node, False - for childen node - def root? - depth == 1 - end - - # Get node height - # - # The height of a node is the number of edges - # on the longest downward path between that node and a leaf. The leaf nodes have height zero, - # and a tree with only a single node (hence both a root and leaf) has height zero. - # Conventionally, an empty tree (tree with no nodes, if such are allowed) has depth and height −1 - # - # @return [Number] height of the given node. Height of the tree for root node. - def height - self_and_descendants.maximum("NLEVEL(#{ltree_path_column})") - depth.to_i - end - - # Get node depth - # - # @return [Integer] node depth - def depth - ActiveRecord::Base.connection.select_all("SELECT NLEVEL('#{ltree_path}')").rows.flatten.first.to_i - end - - # Get root of the node - # - # return [Object] root node - def root - ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} = SUBPATH(?, 0, 1)", ltree_path).first - end - - # Get parent of the node - # - # return [Object] root node - def parent - ltree_scope.find_by "#{self.class.table_name}.#{ltree_path_column} = SUBPATH(?, 0, NLEVEL(?) - 1)", ltree_path, - ltree_path - end - - # Get leaves of the node - # - # @return [ActiveRecord::Relation] - def leaves - ltree_scope.leaves.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", - ltree_path).where.not ltree_path_column => ltree_path - end - - # Check what current node have leaves - # - # @return [Boolean] True - if node have leaves, False - if node doesn't have leaves - def leaf? - leaves.count == 0 - end - - # Get self and ancestors - # - # @return [ActiveRecord::Relation] - def self_and_ancestors - ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} @> ?", ltree_path) - end - - # Get ancestors - # - # @return [ActiveRecord::Relation] - def ancestors - self_and_ancestors.where.not ltree_path_column => ltree_path - end - - # Get self and descendants - # - # @return [ActiveRecord::Relation] - def self_and_descendants - ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path) - end - - # Get descendants - # - # @return [ActiveRecord::Relation] - def descendants - self_and_descendants.where.not ltree_path_column => ltree_path - end - - # Get self and siblings - # - # @return [ActiveRecord::Relation] - def self_and_siblings - ltree_scope.where( - "SUBPATH(?, 0, NLEVEL(?) - 1) @> #{self.class.table_name}.#{ltree_path_column} AND nlevel(#{self.class.table_name}.#{ltree_path_column}) = NLEVEL(?)", - ltree_path, ltree_path, ltree_path - ) - end - - # Get siblings - # - # @return [ActiveRecord::Relation] - def siblings - self_and_siblings.where.not ltree_path_column => ltree_path - end - - # Get children - # - # @return [ActiveRecord::Relation] - def children - ltree_scope.where "? @> #{self.class.table_name}.#{ltree_path_column} AND nlevel(#{self.class.table_name}.#{ltree_path_column}) = NLEVEL(?) + 1", - ltree_path, ltree_path - end - - # Update all childen for current path - # - # @return [ActiveRecord::Relation] - def cascade_update - ltree_scope - .where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_before_last_save) - .where("#{self.class.table_name}.#{ltree_path_column} != ?", ltree_path) - .update_all("#{ltree_path_column} = ? || subpath(#{ltree_path_column}, nlevel(?))", ltree_path, ltree_path_before_last_save) - end - - # Delete all children for current path - # - # @return [ActiveRecord::Relation] - def cascade_destroy - ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_in_database).destroy_all - end + include PathInfo + include Querying + include Traversal + include Predicates + include Cascade end end end diff --git a/lib/pg_ltree/model/cascade.rb b/lib/pg_ltree/model/cascade.rb new file mode 100644 index 0000000..7bf0d58 --- /dev/null +++ b/lib/pg_ltree/model/cascade.rb @@ -0,0 +1,26 @@ +module PgLtree + module Model + module Cascade + extend ActiveSupport::Concern + + included do + # Update all childen for current path + # + # @return [ActiveRecord::Relation] + def cascade_update + ltree_scope + .where(["#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_before_last_save]) + .where(["#{self.class.table_name}.#{ltree_path_column} != ?", ltree_path]) + .update_all(["#{ltree_path_column} = ? || subpath(#{ltree_path_column}, nlevel(?))", ltree_path, ltree_path_before_last_save]) + end + + # Delete all children for current path + # + # @return [ActiveRecord::Relation] + def cascade_destroy + ltree_scope.where("#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path_in_database).destroy_all + end + end + end + end +end diff --git a/lib/pg_ltree/model/path_info.rb b/lib/pg_ltree/model/path_info.rb new file mode 100644 index 0000000..ee6ce89 --- /dev/null +++ b/lib/pg_ltree/model/path_info.rb @@ -0,0 +1,56 @@ +module PgLtree + module Model + module PathInfo + extend ActiveSupport::Concern + + included do + # Get current class as scope + # + # @return current class + def ltree_scope + self.class + end + + # Get lTree column + # + # @return [String] ltree column name + delegate :ltree_path_column, to: :ltree_scope + + # Get lTree value + # + # @return [String] ltree current value + def ltree_path + public_send ltree_path_column + end + + # Get ltree original value before the save just occurred + # + # @return [String] ltree previous value + def ltree_path_before_last_save + public_send :attribute_before_last_save, ltree_path_column + end + + # Get lTree value in database + # + # @return [String] ltree value in database + def ltree_path_in_database + public_send :attribute_in_database, ltree_path_column + end + + # Get node height + # + # @return [Number] height of the given node. + def height + self_and_descendants.maximum("NLEVEL(#{ltree_path_column})") - depth.to_i + end + + # Get node depth + # + # @return [Integer] node depth + def depth + ActiveRecord::Base.connection.select_all("SELECT NLEVEL('#{ltree_path}')").rows.flatten.first.to_i + end + end + end + end +end diff --git a/lib/pg_ltree/model/predicates.rb b/lib/pg_ltree/model/predicates.rb new file mode 100644 index 0000000..7322221 --- /dev/null +++ b/lib/pg_ltree/model/predicates.rb @@ -0,0 +1,23 @@ +module PgLtree + module Model + module Predicates + extend ActiveSupport::Concern + + included do + # Check what current node is root + # + # @return [Boolean] True - for root node, False - for childen node + def root? + depth == 1 + end + + # Check what current node have leaves + # + # @return [Boolean] True - if node have leaves, False - if node doesn't have leaves + def leaf? + leaves.count == 0 + end + end + end + end +end diff --git a/lib/pg_ltree/model/querying.rb b/lib/pg_ltree/model/querying.rb new file mode 100644 index 0000000..d875ef2 --- /dev/null +++ b/lib/pg_ltree/model/querying.rb @@ -0,0 +1,56 @@ +module PgLtree + module Model + module Querying + extend ActiveSupport::Concern + + class_methods do + def ltree_path_column + ltree_option_for :column + end + + # Get roots + # + # @return [ActiveRecord::Relation] relations of node's roots + def roots + at_depth 1 + end + + # Get nodes on the level + # + # @param depth [Integer] Depth of the nodes + # @return [ActiveRecord::Relation] relations of nodes for the depth + def at_depth(depth) + where(["NLEVEL(#{table_name}.#{ltree_path_column}) = ?", depth]) + end + + # Get all leaves + # + # @return [ActiveRecord::Relation] relations of node's leaves + def leaves + subquery = unscoped.select("#{table_name}.#{ltree_path_column}") + .from("#{table_name} AS subquery") + .where("#{table_name}.#{ltree_path_column} <> subquery.#{ltree_path_column}") + .where("#{table_name}.#{ltree_path_column} @> subquery.#{ltree_path_column}") + + where.not ltree_path_column => subquery + end + + # Get all with nodes when path liked the lquery + # + # @param lquery [String] ltree query + # @return [ActiveRecord::Relation] relations of node' + def where_path_liked(lquery) + where(["#{table_name}.#{ltree_path_column} ~ ?", lquery]) + end + + # Get all nodes with path matching full-text-search-like pattern + # + # @param ltxtquery [String] ltree search query + # @return [ActiveRecord::Relation] of matching nodes + def where_path_matches_ltxtquery(ltxtquery) + where(["#{table_name}.#{ltree_path_column} @ ?", ltxtquery]) + end + end + end + end +end diff --git a/lib/pg_ltree/model/traversal.rb b/lib/pg_ltree/model/traversal.rb new file mode 100644 index 0000000..3ad0582 --- /dev/null +++ b/lib/pg_ltree/model/traversal.rb @@ -0,0 +1,82 @@ +module PgLtree + module Model + module Traversal + extend ActiveSupport::Concern + + included do + # Get root of the node + # + # return [Object] root node + def root + ltree_scope.where(["#{self.class.table_name}.#{ltree_path_column} = SUBPATH(?, 0, 1)", ltree_path]).first + end + + # Get parent of the node + # + # return [Object] root node + def parent + ltree_scope.find_by(["#{self.class.table_name}.#{ltree_path_column} = SUBPATH(?, 0, NLEVEL(?) - 1)", ltree_path, ltree_path]) + end + + # Get leaves of the node + # + # @return [ActiveRecord::Relation] + def leaves + ltree_scope.leaves.where(["#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path]).where.not(ltree_path_column => ltree_path) + end + + # Get self and ancestors + # + # @return [ActiveRecord::Relation] + def self_and_ancestors + ltree_scope.where(["#{self.class.table_name}.#{ltree_path_column} @> ?", ltree_path]) + end + + # Get ancestors + # + # @return [ActiveRecord::Relation] + def ancestors + self_and_ancestors.where.not ltree_path_column => ltree_path + end + + # Get self and descendants + # + # @return [ActiveRecord::Relation] + def self_and_descendants + ltree_scope.where(["#{self.class.table_name}.#{ltree_path_column} <@ ?", ltree_path]) + end + + # Get descendants + # + # @return [ActiveRecord::Relation] + def descendants + self_and_descendants.where.not ltree_path_column => ltree_path + end + + # Get self and siblings + # + # @return [ActiveRecord::Relation] + def self_and_siblings + ltree_scope.where( + ["SUBPATH(?, 0, NLEVEL(?) - 1) @> #{self.class.table_name}.#{ltree_path_column} AND nlevel(#{self.class.table_name}.#{ltree_path_column}) = NLEVEL(?)", + ltree_path, ltree_path, ltree_path] + ) + end + + # Get siblings + # + # @return [ActiveRecord::Relation] + def siblings + self_and_siblings.where.not ltree_path_column => ltree_path + end + + # Get children + # + # @return [ActiveRecord::Relation] + def children + ltree_scope.where(["? @> #{self.class.table_name}.#{ltree_path_column} AND nlevel(#{self.class.table_name}.#{ltree_path_column}) = NLEVEL(?) + 1", ltree_path, ltree_path]) + end + end + end + end +end diff --git a/pg_ltree.gemspec b/pg_ltree.gemspec index 44ee2be..ef4be7d 100644 --- a/pg_ltree.gemspec +++ b/pg_ltree.gemspec @@ -24,8 +24,9 @@ Gem::Specification.new do |s| s.add_development_dependency "rake" s.add_development_dependency "pry" s.add_development_dependency "standard" - s.add_development_dependency "yard", "~> 0.9.28" + s.add_development_dependency "yard", "~> 0.9" s.add_development_dependency "appraisal", "~> 2.5" - s.add_development_dependency "rspec", "~> 3.11" - s.add_development_dependency "database_cleaner", "~> 2.0" -end + s.add_development_dependency "rspec", "~> 3.13" + s.add_development_dependency "database_cleaner", "~> 2.1" + s.add_development_dependency "simplecov", "~> 0.22" +end \ No newline at end of file diff --git a/spec/pg_ltree/callbacks_spec.rb b/spec/pg_ltree/callbacks_spec.rb index 63a983d..68d3b30 100644 --- a/spec/pg_ltree/callbacks_spec.rb +++ b/spec/pg_ltree/callbacks_spec.rb @@ -67,6 +67,25 @@ def ltree_scope end end + describe "when cascade is true (deprecated)" do + subject do + Class.new(ActiveRecord::Base) do + self.table_name = "nodes" + ltree :path, cascade: true + end + end + + it "enables cascade update and destroy" do + subject.create!([{path: "Top"}, {path: "Top.Science"}]) + subject.find_by(path: "Top").update path: "NewTop" + expect(subject.pluck(:path)).to include("NewTop", "NewTop.Science") + + subject.find_by(path: "NewTop").destroy + expect(subject.count).to be_zero + end + end + end + context "desctroy records" do describe "when cascade_destroy is true" do subject do diff --git a/spec/pg_ltree/model_spec.rb b/spec/pg_ltree/model_spec.rb index 265642e..b0b2c3c 100644 --- a/spec/pg_ltree/model_spec.rb +++ b/spec/pg_ltree/model_spec.rb @@ -1,12 +1,15 @@ require "spec_helper" RSpec.describe PgLtree::Model do - subject do - Class.new(ActiveRecord::Base) do - self.table_name = "nodes" - ltree :path + context "with default configuration" do + subject do + Class.new(ActiveRecord::Base) do + self.table_name = "nodes" + ltree :path + end end - end + + include_examples "ltree querying" before do subject.create!([ @@ -222,11 +225,23 @@ end describe ".siblings" do - it "returns sibling paths for selected record" do - expect(subject.find_by(path: "Top.Collections.Pictures.Astronomy.Stars").siblings.pluck(:path)).to include(*%w[ - Top.Collections.Pictures.Astronomy.Galaxies - Top.Collections.Pictures.Astronomy.Astronauts - ]) + context "with custom column name" do + subject do + Class.new(ActiveRecord::Base) do + self.table_name = "nodes" + ltree :custom_path_column + end + end + + before do + subject.create!(custom_path_column: "Root") + subject.create!(custom_path_column: "Root.Child") + end + + it "uses the custom column name" do + expect(subject.ltree_path_column).to eq(:custom_path_column) + expect(subject.roots.first.custom_path_column).to eq("Root") + expect(subject.find_by(custom_path_column: "Root").children.first.custom_path_column).to eq("Root.Child") end end end diff --git a/spec/pg_ltree/scope_spec.rb b/spec/pg_ltree/scope_spec.rb new file mode 100644 index 0000000..7184915 --- /dev/null +++ b/spec/pg_ltree/scope_spec.rb @@ -0,0 +1,35 @@ +require "spec_helper" + +RSpec.describe "ltree_scope" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = "nodes" + ltree :path + + attr_accessor :user_id + + def ltree_scope + self.class.where(user_id: user_id) + end + end + end + + before do + model.create!(path: "Top", user_id: 1) + model.create!(path: "Top.Child", user_id: 1) + model.create!(path: "Top", user_id: 2) + model.create!(path: "Top.Child", user_id: 2) + end + + it "scopes results to the current user_id" do + node1 = model.where(user_id: 1, path: "Top").first + node1.user_id = 1 + expect(node1.children.count).to eq(1) + expect(node1.children.first.user_id).to eq(1) + + node2 = model.where(user_id: 2, path: "Top").first + node2.user_id = 2 + expect(node2.children.count).to eq(1) + expect(node2.children.first.user_id).to eq(2) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ae5b703..947b6ac 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,11 @@ require "logger" require "active_record" +require "simplecov" +SimpleCov.start do + add_filter "/spec/" +end + require "pg_ltree" require_relative "support/database" diff --git a/spec/support/schema.rb b/spec/support/schema.rb index 83319c5..8a3a9db 100644 --- a/spec/support/schema.rb +++ b/spec/support/schema.rb @@ -4,5 +4,7 @@ create_table "nodes", force: :cascade do |t| t.ltree "path" + t.ltree "custom_path_column" + t.integer "user_id" end end diff --git a/spec/support/shared_examples/querying_examples.rb b/spec/support/shared_examples/querying_examples.rb new file mode 100644 index 0000000..d230e80 --- /dev/null +++ b/spec/support/shared_examples/querying_examples.rb @@ -0,0 +1,21 @@ +RSpec.shared_examples "ltree querying" do + let(:path_column) { subject.ltree_path_column } + + describe ".roots" do + it "returns all root nodes" do + expect(subject.roots.pluck(path_column)).to include("Top") + end + end + + describe ".at_depth" do + it "returns nodes at a specific depth" do + expect(subject.at_depth(2).pluck(path_column)).to include("Top.Science", "Top.Hobbies") + end + end + + describe ".leaves" do + it "returns nodes with no descendants" do + expect(subject.leaves.pluck(path_column)).to include("Top.Science.Astronomy.Astrophysics") + end + end +end