diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..974865fc --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 2.7.6 diff --git a/lib/pg_search/configuration/association.rb b/lib/pg_search/configuration/association.rb index 8946c194..90faf879 100644 --- a/lib/pg_search/configuration/association.rb +++ b/lib/pg_search/configuration/association.rb @@ -39,13 +39,21 @@ def selects def selects_for_singular_association columns.map do |column| - "#{column.full_name}::text AS #{column.alias}" + if column.tsvector_column + "#{column.full_name}::tsvector AS #{column.alias}" + else + "#{column.full_name}::text AS #{column.alias}" + end end.join(", ") end def selects_for_multiple_association columns.map do |column| - "string_agg(#{column.full_name}::text, ' ') AS #{column.alias}" + if column.tsvector_column + "tsvector_agg(#{column.full_name}) AS #{column.alias}" + else + "string_agg(#{column.full_name}::text, ' ') AS #{column.alias}" + end end.join(", ") end diff --git a/lib/pg_search/configuration/column.rb b/lib/pg_search/configuration/column.rb index 6de06581..b61d4060 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 + @name = column_name.to_s @column_name = column_name - @weight = weight + if weight.is_a?(Hash) + @weight = weight[:weight] + @tsvector_column = weight[:tsvector_column] + else + @weight = weight + end @model = model @connection = model.connection end @@ -22,7 +27,11 @@ def full_name end def to_sql - "coalesce((#{expression})::text, '')" + if tsvector_column + "coalesce((#{expression})::tsvector, '')" + else + "coalesce((#{expression})::text, '')" + end end private diff --git a/lib/pg_search/features/tsearch.rb b/lib/pg_search/features/tsearch.rb index c2a3cc9d..addc804e 100644 --- a/lib/pg_search/features/tsearch.rb +++ b/lib/pg_search/features/tsearch.rb @@ -194,10 +194,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..1584facd 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 8c73e692..e68f594b 100644 --- a/spec/integration/pg_search_spec.rb +++ b/spec/integration/pg_search_spec.rb @@ -1085,7 +1085,312 @@ end end - context "when using multiple tsvector columns" do + 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\")::tsvector, '') || "\ + "coalesce((#{ModelWithTsvector.quoted_table_name}.\"message_tsvector\")::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\")::tsvector, ''), 'A') || "\ + "coalesce((#{ModelWithTsvector.quoted_table_name}.\"content_tsvector\")::tsvector, '') || "\ + "setweight(coalesce((#{ModelWithTsvector.quoted_table_name}.\"message_tsvector\")::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, index: { name: :boopilooopi_id_ix } + 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, index: { name: :hoopaboopa_id_ix } + 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, index: { name: :anoterh_model_id_ix } + 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 include PgSearch::Model diff --git a/spec/lib/pg_search/features/tsearch_spec.rb b/spec/lib/pg_search/features/tsearch_spec.rb index cd4b3ada..e22e9de2 100644 --- a/spec/lib/pg_search/features/tsearch_spec.rb +++ b/spec/lib/pg_search/features/tsearch_spec.rb @@ -142,6 +142,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\")::tsvector, '')) @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))} + ) + end + end end describe "#highlight" do @@ -258,5 +275,22 @@ end # standard: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\")::tsvector, '')) @@ (to_tsquery('simple', ''' ' || 'query' || ' ''')))} + ) + end + end end end diff --git a/spec/support/database.rb b/spec/support/database.rb index 980acb39..23ada4e5 100644 --- a/spec/support/database.rb +++ b/spec/support/database.rb @@ -67,3 +67,4 @@ def load_sql(filename) end load_sql("dmetaphone.sql") +load_sql("tsvector_agg.sql") diff --git a/sql/tsvector_agg.sql b/sql/tsvector_agg.sql new file mode 100644 index 00000000..c5221685 --- /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 OR REPLACE 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);