diff --git a/lib/pg_search/configuration/association.rb b/lib/pg_search/configuration/association.rb index 8946c194..d9c17108 100644 --- a/lib/pg_search/configuration/association.rb +++ b/lib/pg_search/configuration/association.rb @@ -39,7 +39,16 @@ def selects def selects_for_singular_association columns.map do |column| - "#{column.full_name}::text AS #{column.alias}" + if column.tsvector_column + "tsvector_agg(#{column.full_name}) AS #{column.alias}" + else + case postgresql_version + when 0..90000 + "array_to_string(array_agg(#{column.full_name}::text), ' ') AS #{column.alias}" + else + "string_agg(#{column.full_name}::text, ' ') AS #{column.alias}" + end + end end.join(", ") end diff --git a/lib/pg_search/configuration/column.rb b/lib/pg_search/configuration/column.rb index ad1397bc..1e943245 100644 --- a/lib/pg_search/configuration/column.rb +++ b/lib/pg_search/configuration/column.rb @@ -5,12 +5,17 @@ module PgSearch class Configuration class Column - attr_reader :weight, :name + attr_reader :weight, :tsvector_column, :name def initialize(column_name, weight, model) @name = column_name.to_s @column_name = column_name.to_s - @weight = weight + if weight.is_a?(Hash) + @weight = weight[:weight] + @tsvector_column = weight[:tsvector_column] + else + @weight = options + end @model = model @connection = model.connection end @@ -20,7 +25,11 @@ def full_name end def to_sql - "coalesce(#{expression}::text, '')" + if tsvector_column + "coalesce(#{expression}, '')" + else + "coalesce(#{expression}::text, '')" + end end private diff --git a/lib/pg_search/features/tsearch.rb b/lib/pg_search/features/tsearch.rb index baaf2197..82733eae 100644 --- a/lib/pg_search/features/tsearch.rb +++ b/lib/pg_search/features/tsearch.rb @@ -193,10 +193,15 @@ def columns_to_use end def column_to_tsvector(search_column) - tsvector = Arel::Nodes::NamedFunction.new( - "to_tsvector", - [dictionary, Arel.sql(normalize(search_column.to_sql))] - ).to_sql + tsvector = + if search_column.tsvector_column + search_column.to_sql + else + Arel::Nodes::NamedFunction.new( + "to_tsvector", + [dictionary, Arel.sql(normalize(search_column.to_sql))] + ).to_sql + end if search_column.weight.nil? tsvector diff --git a/lib/pg_search/migration/associated_against_tsvector_generator.rb b/lib/pg_search/migration/associated_against_tsvector_generator.rb new file mode 100644 index 00000000..9af543c9 --- /dev/null +++ b/lib/pg_search/migration/associated_against_tsvector_generator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'pg_search/migration/generator' + +module PgSearch + module Migration + class AssociatedAgainstTsvectorGenerator < Generator + def migration_name + 'add_pg_search_associated_against_tsvector_support_functions' + end + end + end +end diff --git a/lib/pg_search/migration/templates/add_pg_search_associated_against_tsvector_support_functions.rb.erb b/lib/pg_search/migration/templates/add_pg_search_associated_against_tsvector_support_functions.rb.erb new file mode 100644 index 00000000..8e0c0fb3 --- /dev/null +++ b/lib/pg_search/migration/templates/add_pg_search_associated_against_tsvector_support_functions.rb.erb @@ -0,0 +1,17 @@ +class AddPgSearchAssociatedAgainstTsvectorSupportFunctions < ActiveRecord::Migration + def self.up + say_with_time("Adding tsvector support functions for pg_search :associated_against") do + execute <<-'SQL' +<%= read_sql_file "tsvector_agg" %> + SQL + end + end + + def self.down + say_with_time("Dropping tsvector support functions for pg_search :associated_against") do + execute <<-'SQL' +<%= read_sql_file "uninstall_tsvector_agg" %> + SQL + end + end +end diff --git a/lib/pg_search/railtie.rb b/lib/pg_search/railtie.rb index a6e9c0b2..36b3a4d3 100644 --- a/lib/pg_search/railtie.rb +++ b/lib/pg_search/railtie.rb @@ -9,6 +9,8 @@ class Railtie < Rails::Railtie generators do require "pg_search/migration/multisearch_generator" require "pg_search/migration/dmetaphone_generator" + require "pg_search/migration/associated_against_generator" + require "pg_search/migration/associated_against_tsvector_generator" end end end diff --git a/spec/integration/pg_search_spec.rb b/spec/integration/pg_search_spec.rb index 6ab50bac..64a86d7e 100644 --- a/spec/integration/pg_search_spec.rb +++ b/spec/integration/pg_search_spec.rb @@ -176,7 +176,7 @@ expect(results).to include(included) expect(results).not_to include(excluded) - expect(results.first.attributes.key?('content')).to eq false + expect(results.first.attributes.key?('content')).to be false expect(results.select { |record| record.title == "bar" }).to eq [included] expect(results.reject { |record| record.title == "bar" }).to be_empty @@ -193,7 +193,7 @@ expect(results).to include(included) expect(results).not_to include(excluded) - expect(results.first.attributes.key?('content')).to eq false + expect(results.first.attributes.key?('content')).to be false expect(results.select { |record| record.title == "bar" }).to eq [included] expect(results.reject { |record| record.title == "bar" }).to be_empty @@ -210,7 +210,7 @@ expect(results).to include(included) expect(results).not_to include(excluded) - expect(results.first.attributes.key?('content')).to eq false + expect(results.first.attributes.key?('content')).to be false expect(results.select { |record| record.title == "bar" }).to eq [included] expect(results.reject { |record| record.title == "bar" }).to be_empty @@ -477,7 +477,7 @@ it "accepts non-string queries and calls #to_s on them" do foo = ModelWithPgSearch.create!(content: "foo") - not_a_string = instance_double("Object", to_s: "foo") + not_a_string = instance_double(Object, to_s: "foo") expect(ModelWithPgSearch.search_content(not_a_string)).to eq([foo]) end @@ -993,13 +993,13 @@ it "passes the custom configuration down to the specified feature" do tsearch_feature = instance_double( - "PgSearch::Features::TSearch", + PgSearch::Features::TSearch, conditions: Arel::Nodes::Grouping.new(Arel.sql("1 = 1")), rank: Arel::Nodes::Grouping.new(Arel.sql("1.0")) ) trigram_feature = instance_double( - "PgSearch::Features::Trigram", + PgSearch::Features::Trigram, conditions: Arel::Nodes::Grouping.new(Arel.sql("1 = 1")), rank: Arel::Nodes::Grouping.new(Arel.sql("1.0")) ) @@ -1074,6 +1074,311 @@ end end + context "with new tsvector column syntax" do + context "when using a tsvector column and an association" do + with_model :Comment do + table do |t| + t.integer :post_id + t.string :body + end + + model do + belongs_to :post + end + end + + with_model :Post do + table do |t| + t.text 'content' + t.tsvector 'content_tsvector' + end + + model do + include PgSearch::Model + has_many :comments + end + end + + let!(:expected) { Post.create!(content: 'phooey') } + let!(:unexpected) { Post.create!(content: 'longcat is looooooooong') } + + before do + ActiveRecord::Base.connection.execute <<~SQL.squish + UPDATE #{Post.quoted_table_name} + SET content_tsvector = to_tsvector('english'::regconfig, #{Post.quoted_table_name}."content") + SQL + + expected.comments.create(body: 'commentone') + unexpected.comments.create(body: 'commentwo') + + Post.pg_search_scope :search_by_content_with_tsvector, + against: { content_tsvector: { tsvector_column: true } }, + associated_against: { comments: [:body] }, + using: { + tsearch: { + dictionary: 'english' + } + } + end + + it "finds by the tsvector column" do + expect(Post.search_by_content_with_tsvector("phooey").map(&:id)).to eq([expected.id]) + end + + it "finds by the associated record" do + expect(Post.search_by_content_with_tsvector("commentone").map(&:id)).to eq([expected.id]) + end + + it 'finds by a combination of the two' do + expect(Post.search_by_content_with_tsvector("phooey commentone").map(&:id)).to eq([expected.id]) + end + end + + context "when using multiple tsvector columns" do + with_model :ModelWithTsvector do + model do + include PgSearch::Model + + pg_search_scope :search_by_multiple_tsvector_columns, + against: { + content_tsvector: { tsvector_column: true }, + message_tsvector: { tsvector_column: true } + }, + using: { + tsearch: { + dictionary: 'english' + } + } + end + end + + it "concats tsvector columns" do + expected = "coalesce(#{ModelWithTsvector.quoted_table_name}.\"content_tsvector\", '') || "\ + "coalesce(#{ModelWithTsvector.quoted_table_name}.\"message_tsvector\", '')" + + expect(ModelWithTsvector.search_by_multiple_tsvector_columns("something").to_sql).to include(expected) + end + end + + context 'using multiple tsvector columns with weight' do + with_model :ModelWithTsvector do + model do + include PgSearch::Model + + pg_search_scope :search_by_multiple_tsvector_columns, + against: { + title_tsvector: { tsvector_column: true, weight: 'A' }, + content_tsvector: { tsvector_column: true }, + message_tsvector: { tsvector_column: true, weight: 'B' } + }, + using: { + tsearch: { + dictionary: 'english' + } + } + end + end + + it 'concats tsvector columns' do + expected = "setweight(coalesce(#{ModelWithTsvector.quoted_table_name}.\"title_tsvector\", ''), 'A') || "\ + "coalesce(#{ModelWithTsvector.quoted_table_name}.\"content_tsvector\", '') || "\ + "setweight(coalesce(#{ModelWithTsvector.quoted_table_name}.\"message_tsvector\", ''), 'B')" + + expect(ModelWithTsvector.search_by_multiple_tsvector_columns("something").to_sql).to include(expected) + end + end + + context "using a tsvector column with" do + with_model :ModelWithTsvector do + table do |t| + t.text 'content' + t.tsvector 'content_tsvector' + end + + model { include PgSearch::Model } + end + + let!(:expected) { ModelWithTsvector.create!(content: 'tiling is grouty') } + let!(:unexpected) { ModelWithTsvector.create!(content: 'longcat is looooooooong') } + + before do + ActiveRecord::Base.connection.execute <<~SQL.squish + UPDATE #{ModelWithTsvector.quoted_table_name} + SET content_tsvector = to_tsvector('english'::regconfig, #{ModelWithTsvector.quoted_table_name}."content") + SQL + + ModelWithTsvector.pg_search_scope :search_by_content_with_tsvector, + against: { content_tsvector: { tsvector_column: true } }, + using: { + tsearch: { + dictionary: 'english' + } + } + end + + it "does not use to_tsvector in the query" do + expect(ModelWithTsvector.search_by_content_with_tsvector("tiles").to_sql).not_to match(/to_tsvector/) + end + + it "finds the expected result" do + expect(ModelWithTsvector.search_by_content_with_tsvector("tiles").map(&:id)).to eq([expected.id]) + end + + context "when joining to a table with a column of the same name" do + with_model :AnotherModel do + table do |t| + t.string :content_tsvector # the type of the column doesn't matter + t.belongs_to :model_with_tsvector + end + end + + before do + ModelWithTsvector.has_many :another_models + end + + it "refers to the tsvector column in the query unambiguously" do + expect { + ModelWithTsvector.joins(:another_models).search_by_content_with_tsvector("test").to_a + }.not_to raise_exception + end + end + + context "when joining to a table with a tsvector column" do + with_model :AnotherModelWithTsvector do + table do |t| + t.text 'content' + t.tsvector 'content_tsvector' + t.belongs_to :model_with_tsvector + end + end + + before do + ModelWithTsvector.has_many :another_model_with_tsvectors + + ModelWithTsvector.pg_search_scope :search_by_content_with_tsvectors, + against: { content_tsvector: { tsvector_column: true } }, + associated_against: { + another_model_with_tsvectors: { + content_tsvector: { tsvector_column: true } + } + }, + using: { + tsearch: { + dictionary: 'english' + } + } + end + + it "refers to each tsvector column in the query unambiguously" do + expect { + ModelWithTsvector.joins(:another_model_with_tsvectors).search_by_content_with_tsvectors("test").to_a + }.not_to raise_exception + end + + it "does not use to_tsvector in the query" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("tiles").to_sql).not_to match(/to_tsvector/) + end + + it "finds the expected result" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("tiles").map(&:id)).to eq([expected.id]) + end + + describe 'with associated records' do + before do + AnotherModelWithTsvector.create!(content: 'monkeys like bananas', model_with_tsvector_id: expected.id) + + ActiveRecord::Base.connection.execute <<~SQL.squish + UPDATE #{AnotherModelWithTsvector.quoted_table_name} + SET content_tsvector = to_tsvector('english'::regconfig, #{AnotherModelWithTsvector.quoted_table_name}."content") + SQL + end + + it "does not use to_tsvector in the query" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("bananas").to_sql).not_to match(/to_tsvector/) + end + + it "finds the expected result" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("bananas").map(&:id)).to eq([expected.id]) + end + end + end + end + + context "mixed with old syntax" do + with_model :ModelWithTsvector do + table do |t| + t.text 'content' + t.tsvector 'content_tsvector' + end + + model { include PgSearch::Model } + end + + with_model :AnotherModelWithTsvector do + table do |t| + t.text 'content' + t.tsvector 'content_tsvector' + t.belongs_to :model_with_tsvector + end + end + + let!(:expected) { ModelWithTsvector.create!(content: 'tiling is grouty') } + let!(:unexpected) { ModelWithTsvector.create!(content: 'longcat is looooooooong') } + + before do + ModelWithTsvector.has_many :another_model_with_tsvectors + + ModelWithTsvector.pg_search_scope :search_by_content_with_tsvectors, + against: :content, + associated_against: { + another_model_with_tsvectors: { + content_tsvector: { tsvector_column: true } + } + }, + using: { + tsearch: { + tsvector_column: 'content_tsvector', + dictionary: 'english' + } + } + + AnotherModelWithTsvector.create!(content: 'monkeys like bananas', model_with_tsvector_id: expected.id) + + ActiveRecord::Base.connection.execute <<~SQL.squish + UPDATE #{ModelWithTsvector.quoted_table_name} + SET content_tsvector = to_tsvector('english'::regconfig, #{ModelWithTsvector.quoted_table_name}."content") + SQL + + ActiveRecord::Base.connection.execute <<~SQL.squish + UPDATE #{AnotherModelWithTsvector.quoted_table_name} + SET content_tsvector = to_tsvector('english'::regconfig, #{AnotherModelWithTsvector.quoted_table_name}."content") + SQL + end + + it "refers to each tsvector column in the query unambiguously" do + expect { + ModelWithTsvector.joins(:another_model_with_tsvectors).search_by_content_with_tsvectors("test").to_a + }.not_to raise_exception + end + + it "does not use to_tsvector in the query" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("tiles").to_sql).not_to match(/to_tsvector/) + end + + it "finds the expected result" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("tiles").map(&:id)).to eq([expected.id]) + end + + it "does not use to_tsvector in the query" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("bananas").to_sql).not_to match(/to_tsvector/) + end + + it "finds the expected result" do + expect(ModelWithTsvector.search_by_content_with_tsvectors("bananas").map(&:id)).to eq([expected.id]) + end + end + end + context 'when using multiple tsvector columns' do with_model :ModelWithTsvector do model do @@ -1091,7 +1396,7 @@ end it 'concats tsvector columns' do - expected = "#{ModelWithTsvector.quoted_table_name}.\"content_tsvector\" || "\ + expected = "#{ModelWithTsvector.quoted_table_name}.\"content_tsvector\" || " \ "#{ModelWithTsvector.quoted_table_name}.\"message_tsvector\"" expect(ModelWithTsvector.search_by_multiple_tsvector_columns("something").to_sql).to include(expected) @@ -1174,11 +1479,16 @@ end context "when the query includes accents" do - it "does not create an erroneous tsquery expression" do - included = ModelWithPgSearch.create!(title: "Weird L‘Content") + let(:term) { "L#{%w[‘ ’ ʻ ʼ].sample}Content" } + let(:included) { ModelWithPgSearch.create!(title: "Weird #{term}") } + let(:results) { ModelWithPgSearch.search_title_without_accents(term) } - results = ModelWithPgSearch.search_title_without_accents("L‘Content") - expect(results).to eq([included]) + before do + ModelWithPgSearch.create!(title: 'FooBar') + end + + it "does not create an erroneous tsquery expression" do + expect(results).to contain_exactly(included) end end end diff --git a/spec/lib/pg_search/configuration/association_spec.rb b/spec/lib/pg_search/configuration/association_spec.rb index 660b890e..6c7d79d2 100644 --- a/spec/lib/pg_search/configuration/association_spec.rb +++ b/spec/lib/pg_search/configuration/association_spec.rb @@ -1,142 +1,222 @@ -# frozen_string_literal: true - require "spec_helper" -# rubocop:disable RSpec/NestedGroups describe PgSearch::Configuration::Association do - with_model :Avatar do - table do |t| - t.string :url - t.references :user + context "through a belongs_to association" do + with_model :AssociatedModel do + table do |t| + t.string "title" + end end - end - with_model :User do - table do |t| - t.string :name - t.belongs_to :site + with_model :Model do + table do |t| + t.string "title" + t.belongs_to :another_model + end + + model do + include PgSearch + belongs_to :another_model, :class_name => 'AssociatedModel' + + pg_search_scope :with_another, :associated_against => {:another_model => :title} + end end - model do - include PgSearch::Model - has_one :avatar, class_name: "Avatar" - belongs_to :site + let(:association) { described_class.new(Model, :another_model, :title) } + + describe "#table_name" do + it "returns the table name for the associated model" do + expect(association.table_name).to eq AssociatedModel.table_name + end + end + + describe "#join" do + context "given any postgresql_version" do + let(:column_select) do + "\"#{association.table_name}\".\"title\"" + end + + let(:expected_sql) do + <<-EOS.gsub(/\s+/, ' ').strip + LEFT OUTER JOIN + (SELECT model_id AS id, + #{column_select} AS #{association.columns.first.alias} + FROM \"#{Model.table_name}\" + INNER JOIN \"#{association.table_name}\" + ON \"#{association.table_name}\".\"id\" = \"#{Model.table_name}\".\"another_model_id\") #{association.subselect_alias} + ON #{association.subselect_alias}.id = model_id + EOS + end + + it "returns the correct SQL join (v1)" do + allow(Model.connection).to receive(:postgresql_version).and_return(1) + expect(association.join("model_id")).to eq(expected_sql) + end - pg_search_scope :with_avatar, associated_against: { avatar: :url } - pg_search_scope :with_site, associated_against: { site: :title } + it "returns the correct SQL join (v100)" do + allow(Model.connection).to receive(:postgresql_version).and_return(100_000) + expect(association.join("model_id")).to eq(expected_sql) + end + end + end + + describe "#subselect_alias" do + it "returns a consistent string" do + subselect_alias = association.subselect_alias + expect(subselect_alias).to be_a String + expect(association.subselect_alias).to eq subselect_alias + end end end - with_model :Site do - table do |t| - t.string :title + context "through a has_one association" do + with_model :Model do + table do |t| + t.string "title" + end + + model do + include PgSearch + has_one :another_model, :class_name => 'AssociatedModel', foreign_key: 'primary_model_id' + + pg_search_scope :with_another, :associated_against => {:another_model => :title} + end end - model do - include PgSearch::Model - has_many :users, class_name: "User" + with_model :AssociatedModel do + table do |t| + t.string "title" + t.belongs_to :primary_model + end - pg_search_scope :with_users, associated_against: { users: :name } + model do + belongs_to :primary_model, :class_name => 'Model' + end end - end - context "with has_one" do - let(:association) { described_class.new(User, :avatar, :url) } + let(:association) { described_class.new(Model, :another_model, :title) } describe "#table_name" do it "returns the table name for the associated model" do - expect(association.table_name).to eq Avatar.table_name + expect(association.table_name).to eq AssociatedModel.table_name end end describe "#join" do - let(:expected_sql) do - <<~SQL.squish - LEFT OUTER JOIN - (SELECT model_id AS id, - #{column_select} AS #{association.columns.first.alias} - FROM \"#{User.table_name}\" - INNER JOIN \"#{association.table_name}\" - ON \"#{association.table_name}\".\"user_id\" = \"#{User.table_name}\".\"id\") #{association.subselect_alias} - ON #{association.subselect_alias}.id = model_id - SQL - end - let(:column_select) do - "\"#{association.table_name}\".\"url\"::text" + context "given any postgresql_version" do + let(:column_select) do + "\"#{association.table_name}\".\"title\"" + end + + let(:expected_sql) do + <<-EOS.gsub(/\s+/, ' ').strip + LEFT OUTER JOIN + (SELECT model_id AS id, + #{column_select} AS #{association.columns.first.alias} + FROM \"#{Model.table_name}\" + INNER JOIN \"#{association.table_name}\" + ON \"#{association.table_name}\".\"primary_model_id\" = \"#{Model.table_name}\".\"id\") #{association.subselect_alias} + ON #{association.subselect_alias}.id = model_id + EOS + end + + it "returns the correct SQL join (v1)" do + allow(Model.connection).to receive(:postgresql_version).and_return(1) + expect(association.join("model_id")).to eq(expected_sql) + end + + it "returns the correct SQL join (v100)" do + allow(Model.connection).to receive(:postgresql_version).and_return(100_000) + expect(association.join("model_id")).to eq(expected_sql) + end end + end - it "returns the correct SQL join" do - expect(association.join("model_id")).to eq(expected_sql) + describe "#subselect_alias" do + it "returns a consistent string" do + subselect_alias = association.subselect_alias + expect(subselect_alias).to be_a String + expect(association.subselect_alias).to eq subselect_alias end end end - context "with belongs_to" do - let(:association) { described_class.new(User, :site, :title) } + context "through a has_many association" do + with_model :Model do + table do |t| + t.string "title" + end + + model do + include PgSearch + has_many :associated_models, :class_name => 'AssociatedModel', foreign_key: 'primary_model_id' - describe "#table_name" do - it "returns the table name for the associated model" do - expect(association.table_name).to eq Site.table_name + pg_search_scope :with_another, :associated_against => {:another_model => :title} end end - describe "#join" do - let(:expected_sql) do - <<~SQL.squish - LEFT OUTER JOIN - (SELECT model_id AS id, - #{column_select} AS #{association.columns.first.alias} - FROM \"#{User.table_name}\" - INNER JOIN \"#{association.table_name}\" - ON \"#{association.table_name}\".\"id\" = \"#{User.table_name}\".\"site_id\") #{association.subselect_alias} - ON #{association.subselect_alias}.id = model_id - SQL - end - let(:column_select) do - "\"#{association.table_name}\".\"title\"::text" + with_model :AssociatedModel do + table do |t| + t.string "title" + t.belongs_to :primary_model end - it "returns the correct SQL join" do - expect(association.join("model_id")).to eq(expected_sql) + model do + belongs_to :primary_model, :class_name => 'Model' end end - end - context "with has_many" do - let(:association) { described_class.new(Site, :users, :name) } + let(:association) { described_class.new(Model, :associated_models, :title) } describe "#table_name" do it "returns the table name for the associated model" do - expect(association.table_name).to eq User.table_name + expect(association.table_name).to eq AssociatedModel.table_name end end describe "#join" do let(:expected_sql) do - <<~SQL.squish + <<-EOS.gsub(/\s+/, ' ').strip LEFT OUTER JOIN (SELECT model_id AS id, - string_agg(\"#{association.table_name}\".\"name\"::text, ' ') AS #{association.columns.first.alias} - FROM \"#{Site.table_name}\" + #{column_select} AS #{association.columns.first.alias} + FROM \"#{Model.table_name}\" INNER JOIN \"#{association.table_name}\" - ON \"#{association.table_name}\".\"site_id\" = \"#{Site.table_name}\".\"id\" + ON \"#{association.table_name}\".\"primary_model_id\" = \"#{Model.table_name}\".\"id\" GROUP BY model_id) #{association.subselect_alias} ON #{association.subselect_alias}.id = model_id - SQL + EOS end - it "returns the correct SQL join" do - expect(association.join("model_id")).to eq(expected_sql) + context "given postgresql_version 0..90_000" do + let(:column_select) do + "array_to_string(array_agg(\"#{association.table_name}\".\"title\"::text), ' ')" + end + + it "returns the correct SQL join" do + allow(Model.connection).to receive(:postgresql_version).and_return(1) + expect(association.join("model_id")).to eq(expected_sql) + end end - describe "#subselect_alias" do - it "returns a consistent string" do - subselect_alias = association.subselect_alias - expect(subselect_alias).to be_a String - expect(association.subselect_alias).to eq subselect_alias + context "given any other postgresql_version" do + let(:column_select) do + "string_agg(\"#{association.table_name}\".\"title\"::text, ' ')" + end + + it "returns the correct SQL join" do + allow(Model.connection).to receive(:postgresql_version).and_return(100_000) + expect(association.join("model_id")).to eq(expected_sql) end end end + + describe "#subselect_alias" do + it "returns a consistent string" do + subselect_alias = association.subselect_alias + expect(subselect_alias).to be_a String + expect(association.subselect_alias).to eq subselect_alias + end + end end end -# rubocop:enable RSpec/NestedGroups diff --git a/spec/lib/pg_search/features/tsearch_spec.rb b/spec/lib/pg_search/features/tsearch_spec.rb index f493c56e..ceab7e26 100644 --- a/spec/lib/pg_search/features/tsearch_spec.rb +++ b/spec/lib/pg_search/features/tsearch_spec.rb @@ -124,6 +124,23 @@ ) end end + + context "when column is a tsvector_column" do + it 'uses the tsvector column' do + query = "query" + columns = [ + PgSearch::Configuration::Column.new(:my_tsvector, { tsvector_column: true }, Model), + ] + options = { } + config = double(:config, :ignore => []) + normalizer = PgSearch::Normalizer.new(config) + + feature = described_class.new(query, options, columns, Model, normalizer) + expect(feature.conditions.to_sql).to eq( + %Q{((coalesce(#{Model.quoted_table_name}.\"my_tsvector\", '')) @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))} + ) + end + end end describe "#highlight" do @@ -240,5 +257,22 @@ end # rubocop:enable RSpec/ExampleLength end + + context "when column is a tsvector_column" do + it 'uses the tsvector column' do + query = "query" + columns = [ + PgSearch::Configuration::Column.new(:my_tsvector, { tsvector_column: true }, Model) + ] + options = {} + config = double(:config, ignore: []) + normalizer = PgSearch::Normalizer.new(config) + + feature = described_class.new(query, options, columns, Model, normalizer) + expect(feature.conditions.to_sql).to eq( + %{((coalesce(#{Model.quoted_table_name}.\"my_tsvector\", '')) @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))} + ) + end + end end end diff --git a/spec/support/database.rb b/spec/support/database.rb index ea31b63f..11d3ef00 100644 --- a/spec/support/database.rb +++ b/spec/support/database.rb @@ -67,3 +67,5 @@ def load_sql(filename) end load_sql("dmetaphone.sql") + +load_sql("tsvector_agg.sql") unless connection.select_value("SELECT 1 FROM pg_catalog.pg_aggregate WHERE aggfnoid = 'tsvector_agg'::REGPROC") == "1" diff --git a/sql/tsvector_agg.sql b/sql/tsvector_agg.sql new file mode 100644 index 00000000..6848d975 --- /dev/null +++ b/sql/tsvector_agg.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE FUNCTION concat_tsvectors(tsv1 tsvector, tsv2 tsvector) RETURNS tsvector AS +$function$ BEGIN + RETURN tsv1 || tsv2; +END; $function$ +LANGUAGE plpgsql; + +CREATE AGGREGATE tsvector_agg(tsvector) ( + SFUNC=concat_tsvectors, + STYPE=tsvector, + INITCOND='' +); diff --git a/sql/uninstall_tsvector_agg.sql b/sql/uninstall_tsvector_agg.sql new file mode 100644 index 00000000..06356b37 --- /dev/null +++ b/sql/uninstall_tsvector_agg.sql @@ -0,0 +1,3 @@ +DROP AGGREGATE IF EXISTS tsvector_agg(tsvector); + +DROP FUNCTION IF EXISTS concat_tsvectors(tsvector, tsvector);