From f2154ec8e1e71115162fb1241944d16a9fb28814 Mon Sep 17 00:00:00 2001 From: Scott Holden Date: Wed, 24 Jun 2020 12:32:31 -0700 Subject: [PATCH 1/2] Add the ability to associate arel table alias nodes to a search model --- lib/pg_search/configuration.rb | 20 +++- .../configuration/arel_association.rb | 44 +++++++ spec/integration/associations_spec.rb | 107 ++++++++++++++++++ 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 lib/pg_search/configuration/arel_association.rb diff --git a/lib/pg_search/configuration.rb b/lib/pg_search/configuration.rb index 2687b57b..253b62b3 100644 --- a/lib/pg_search/configuration.rb +++ b/lib/pg_search/configuration.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "pg_search/configuration/association" +require "pg_search/configuration/arel_association" require "pg_search/configuration/column" require "pg_search/configuration/foreign_column" @@ -27,6 +28,10 @@ def columns regular_columns + associated_columns end + def associations + relation_associations + arel_associations + end + def regular_columns return [] unless options[:against] @@ -35,7 +40,7 @@ def regular_columns end end - def associations + def relation_associations return [] unless options[:associated_against] options[:associated_against].map do |association, column_names| @@ -43,6 +48,13 @@ def associations end.flatten end + def arel_associations + return [] unless options[:associated_arel] + options[:associated_arel].map do |arel, column_names| + ArelAssociation.new(model, arel, column_names) + end.flatten + end + def associated_columns associations.map(&:columns).flatten end @@ -84,7 +96,7 @@ def default_options end VALID_KEYS = %w[ - against ranked_by ignoring using query associated_against order_within_rank + against ranked_by ignoring using query associated_against associated_arel order_within_rank ].map(&:to_sym) VALID_VALUES = { @@ -92,8 +104,8 @@ def default_options }.freeze def assert_valid_options(options) - unless options[:against] || options[:associated_against] - raise ArgumentError, "the search scope #{@name} must have :against or :associated_against in its options" + unless options[:against] || options[:associated_against] || options[:associated_arel] + raise ArgumentError, "the search scope #{@name} must have :against or :associated_against or :associated_arel in its options" end options.assert_valid_keys(VALID_KEYS) diff --git a/lib/pg_search/configuration/arel_association.rb b/lib/pg_search/configuration/arel_association.rb new file mode 100644 index 00000000..69c67a8b --- /dev/null +++ b/lib/pg_search/configuration/arel_association.rb @@ -0,0 +1,44 @@ +require "digest" + +module PgSearch + class Configuration + class ArelAssociation + attr_reader :columns + + # ArelAssociation accepts an Arel::Nodes::TableAlias and pulls specified columns into the search query + # For this to work, the TableAlias needs an `id` column that corresponds to the primary key of the root + # search model where the associated arel is specified. + def initialize(model, arel, column_names) + @model = model + @arel = arel + @columns = Array(column_names).map do |column_name, weight| + ForeignColumn.new(column_name, weight, @model, self) + end + end + + def table_name + @arel.name.to_s + end + + def join(primary_key) + "LEFT OUTER JOIN (#{select_manager.to_sql}) #{subselect_alias} ON #{subselect_alias}.id = #{primary_key}" + end + + def subselect_alias + Configuration.alias(table_name, "subselect") + end + + private + + def selects + columns.map do |column| + "string_agg(#{column.full_name}::text, ' ') AS #{column.alias}" + end.join(", ") + end + + def select_manager + Arel::SelectManager.new(@arel).project(Arel.sql('id'), Arel.sql(selects)).group(@arel[:id]) + end + end + end +end diff --git a/spec/integration/associations_spec.rb b/spec/integration/associations_spec.rb index d4f001f8..f99ddca7 100644 --- a/spec/integration/associations_spec.rb +++ b/spec/integration/associations_spec.rb @@ -482,4 +482,111 @@ expect(results).not_to include(*excluded) end end + + context "through an Arel Node" do + with_model :AssociatedDog do + table do |t| + t.string 'dog_name' + end + + model do + def name + dog_name + end + end + end + + with_model :AssociatedCat do + table do |t| + t.string 'cat_name' + end + + model do + def name + cat_name + end + end + end + + with_model :AssociatedModelWithHasManyAndPolymorphicPet do + table do |t| + t.string 'title' + t.belongs_to 'ModelWithHasMany', index: false + t.references :polymorphic_pet, polymorphic: true, index: false + end + + model do + belongs_to :polymorphic_pet, polymorphic: true + end + end + + with_model :ModelWithHasMany do + table do |t| + t.string 'title' + end + + model do + include PgSearch::Model + has_many :other_models, :class_name => 'AssociatedModelWithHasManyAndPolymorphicPet', :foreign_key => 'ModelWithHasMany_id' + + def self.associated_pet_names + cat_table = AssociatedCat.arel_table + dog_table = AssociatedDog.arel_table + bt_table = AssociatedModelWithHasManyAndPolymorphicPet.arel_table + + cat_join = bt_table.join(cat_table).on(bt_table[:polymorphic_pet_id].eq(cat_table[:id]).and(bt_table[:polymorphic_pet_type].eq('AssociatedCat'))) + .project(bt_table[:ModelWithHasMany_id].as('id'), cat_table[:cat_name].as('name')) + dog_join = bt_table.join(dog_table).on(bt_table[:polymorphic_pet_id].eq(dog_table[:id]).and(bt_table[:polymorphic_pet_type].eq('AssociatedDog'))) + .project(bt_table[:ModelWithHasMany_id].as('id'), dog_table[:dog_name].as('name')) + + union = cat_join.union(dog_join) + Arel::Nodes::TableAlias.new(union, 'associated_pet_names') + end + + pg_search_scope :with_associated, + against: :title, + associated_against: { + other_models: [:title] + }, + associated_arel: { + associated_pet_names => :name + } + end + end + + it "returns rows that match the query in either its own columns or the columns of the associated model" do + felix = AssociatedCat.create!(cat_name: 'felix') + garfield = AssociatedCat.create!(cat_name: 'garfield') + snoopy = AssociatedDog.create!(dog_name: 'snoopy') + goofy = AssociatedDog.create!(dog_name: 'goofy') + + included = [ + # covered by polymorphic arel column + ModelWithHasMany.create!(:title => 'abcdef', :other_models => [ + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo', polymorphic_pet: felix), + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'bar', polymorphic_pet: goofy) + ]), + # covered by associated column + ModelWithHasMany.create!(:title => 'ghijkl', :other_models => [ + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo bar', polymorphic_pet: felix), + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'goofy', polymorphic_pet: snoopy) + ]), + # covered by model column + ModelWithHasMany.create!(:title => 'felix', :other_models => [ + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo bar', polymorphic_pet: garfield), + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'goofy', polymorphic_pet: snoopy) + ]) + ] + excluded = ModelWithHasMany.create!(:title => 'stuvwx', :other_models => [ + # not covered + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo bar', polymorphic_pet: felix), + AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'mnopqr', polymorphic_pet: snoopy) + ]) + + results = ModelWithHasMany.with_associated('felix goofy') + expect(results.flat_map{|r| r.other_models.map(&:polymorphic_pet).map(&:name)}).to( + match_array(included.flat_map{|r| r.other_models.map(&:polymorphic_pet).map(&:name)})) + expect(results).not_to include(excluded) + end + end end From c6e0bba67409be24e38c0afa5aaca67dedbc74a4 Mon Sep 17 00:00:00 2001 From: Scott Holden Date: Wed, 24 Jun 2020 13:26:01 -0700 Subject: [PATCH 2/2] update formatting for rubocop --- lib/pg_search/configuration.rb | 4 +- .../configuration/arel_association.rb | 2 + spec/integration/associations_spec.rb | 59 ++++++++++--------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/lib/pg_search/configuration.rb b/lib/pg_search/configuration.rb index 253b62b3..24133af9 100644 --- a/lib/pg_search/configuration.rb +++ b/lib/pg_search/configuration.rb @@ -50,6 +50,7 @@ def relation_associations def arel_associations return [] unless options[:associated_arel] + options[:associated_arel].map do |arel, column_names| ArelAssociation.new(model, arel, column_names) end.flatten @@ -105,7 +106,8 @@ def default_options def assert_valid_options(options) unless options[:against] || options[:associated_against] || options[:associated_arel] - raise ArgumentError, "the search scope #{@name} must have :against or :associated_against or :associated_arel in its options" + raise ArgumentError, + "the search scope #{@name} must have :against or :associated_against or :associated_arel in its options" end options.assert_valid_keys(VALID_KEYS) diff --git a/lib/pg_search/configuration/arel_association.rb b/lib/pg_search/configuration/arel_association.rb index 69c67a8b..e409dcad 100644 --- a/lib/pg_search/configuration/arel_association.rb +++ b/lib/pg_search/configuration/arel_association.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "digest" module PgSearch diff --git a/spec/integration/associations_spec.rb b/spec/integration/associations_spec.rb index f99ddca7..d262c51a 100644 --- a/spec/integration/associations_spec.rb +++ b/spec/integration/associations_spec.rb @@ -483,7 +483,7 @@ end end - context "through an Arel Node" do + context "when through an Arel Node" do with_model :AssociatedDog do table do |t| t.string 'dog_name' @@ -527,29 +527,29 @@ def name model do include PgSearch::Model - has_many :other_models, :class_name => 'AssociatedModelWithHasManyAndPolymorphicPet', :foreign_key => 'ModelWithHasMany_id' + has_many :other_models, class_name: 'AssociatedModelWithHasManyAndPolymorphicPet', foreign_key: 'ModelWithHasMany_id' + # rubocop:disable Metrics/AbcSize def self.associated_pet_names cat_table = AssociatedCat.arel_table dog_table = AssociatedDog.arel_table bt_table = AssociatedModelWithHasManyAndPolymorphicPet.arel_table cat_join = bt_table.join(cat_table).on(bt_table[:polymorphic_pet_id].eq(cat_table[:id]).and(bt_table[:polymorphic_pet_type].eq('AssociatedCat'))) - .project(bt_table[:ModelWithHasMany_id].as('id'), cat_table[:cat_name].as('name')) + .project(bt_table[:ModelWithHasMany_id].as('id'), cat_table[:cat_name].as('name')) dog_join = bt_table.join(dog_table).on(bt_table[:polymorphic_pet_id].eq(dog_table[:id]).and(bt_table[:polymorphic_pet_type].eq('AssociatedDog'))) - .project(bt_table[:ModelWithHasMany_id].as('id'), dog_table[:dog_name].as('name')) + .project(bt_table[:ModelWithHasMany_id].as('id'), dog_table[:dog_name].as('name')) - union = cat_join.union(dog_join) - Arel::Nodes::TableAlias.new(union, 'associated_pet_names') + Arel::Nodes::TableAlias.new(cat_join.union(dog_join), 'associated_pet_names') end pg_search_scope :with_associated, against: :title, associated_against: { - other_models: [:title] + other_models: [:title] }, associated_arel: { - associated_pet_names => :name + associated_pet_names => :name } end end @@ -561,31 +561,32 @@ def self.associated_pet_names goofy = AssociatedDog.create!(dog_name: 'goofy') included = [ - # covered by polymorphic arel column - ModelWithHasMany.create!(:title => 'abcdef', :other_models => [ - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo', polymorphic_pet: felix), - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'bar', polymorphic_pet: goofy) - ]), - # covered by associated column - ModelWithHasMany.create!(:title => 'ghijkl', :other_models => [ - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo bar', polymorphic_pet: felix), - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'goofy', polymorphic_pet: snoopy) - ]), - # covered by model column - ModelWithHasMany.create!(:title => 'felix', :other_models => [ - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo bar', polymorphic_pet: garfield), - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'goofy', polymorphic_pet: snoopy) - ]) + # covered by polymorphic arel column + ModelWithHasMany.create!(title: 'abcdef', other_models: [ + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'foo', polymorphic_pet: felix), + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'bar', polymorphic_pet: goofy) + ]), + # covered by associated column + ModelWithHasMany.create!(title: 'ghijkl', other_models: [ + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'foo bar', polymorphic_pet: felix), + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'goofy', polymorphic_pet: snoopy) + ]), + # covered by model column + ModelWithHasMany.create!(title: 'felix', other_models: [ + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'foo bar', polymorphic_pet: garfield), + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'goofy', polymorphic_pet: snoopy) + ]) ] - excluded = ModelWithHasMany.create!(:title => 'stuvwx', :other_models => [ - # not covered - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'foo bar', polymorphic_pet: felix), - AssociatedModelWithHasManyAndPolymorphicPet.create!(:title => 'mnopqr', polymorphic_pet: snoopy) + excluded = ModelWithHasMany.create!(title: 'stuvwx', other_models: [ + # not covered + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'foo bar', polymorphic_pet: felix), + AssociatedModelWithHasManyAndPolymorphicPet.create!(title: 'mnopqr', polymorphic_pet: snoopy) ]) results = ModelWithHasMany.with_associated('felix goofy') - expect(results.flat_map{|r| r.other_models.map(&:polymorphic_pet).map(&:name)}).to( - match_array(included.flat_map{|r| r.other_models.map(&:polymorphic_pet).map(&:name)})) + expect(results.flat_map { |r| r.other_models.map(&:polymorphic_pet).map(&:name) }).to( + match_array(included.flat_map { |r| r.other_models.map(&:polymorphic_pet).map(&:name) }) + ) expect(results).not_to include(excluded) end end