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..17c54a88 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 - @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 6f8ad382..c6e6899f 100644 --- a/lib/pg_search/features/tsearch.rb +++ b/lib/pg_search/features/tsearch.rb @@ -186,10 +186,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..c8cc6526 --- /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..4f4ae0da 100644 --- a/lib/pg_search/railtie.rb +++ b/lib/pg_search/railtie.rb @@ -9,6 +9,7 @@ class Railtie < Rails::Railtie generators do require "pg_search/migration/multisearch_generator" require "pg_search/migration/dmetaphone_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 e573c270..54c26f26 100644 --- a/spec/integration/pg_search_spec.rb +++ b/spec/integration/pg_search_spec.rb @@ -1112,6 +1112,312 @@ 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\")::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 table diff --git a/spec/lib/pg_search/features/tsearch_spec.rb b/spec/lib/pg_search/features/tsearch_spec.rb index cd4b3ada..4d92d669 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( + %{((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);