diff --git a/lib/pg_search/configuration.rb b/lib/pg_search/configuration.rb index 2687b57b..24133af9 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,14 @@ 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 +97,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 +105,9 @@ 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..e409dcad --- /dev/null +++ b/lib/pg_search/configuration/arel_association.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +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..d262c51a 100644 --- a/spec/integration/associations_spec.rb +++ b/spec/integration/associations_spec.rb @@ -482,4 +482,112 @@ expect(results).not_to include(*excluded) end end + + context "when 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' + + # 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')) + 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')) + + 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] + }, + 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