Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to associate arel table alias nodes to a search model #443

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions lib/pg_search/configuration.rb
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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]

Expand All @@ -35,14 +40,22 @@ def regular_columns
end
end

def associations
def relation_associations
return [] unless options[:associated_against]

options[:associated_against].map do |association, column_names|
Association.new(model, association, column_names)
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
Expand Down Expand Up @@ -84,16 +97,17 @@ 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 = {
ignoring: [:accents]
}.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)
Expand Down
46 changes: 46 additions & 0 deletions lib/pg_search/configuration/arel_association.rb
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions spec/integration/associations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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